<?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>USDT on Tron and the energy bill: what sweeping TRC-20 deposits really costs</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Sun, 15 Nov 2026 10:09:06 +0000</pubDate>
      <link>https://dev.to/coldtill/usdt-on-tron-and-the-energy-bill-what-sweeping-trc-20-deposits-really-costs-52ji</link>
      <guid>https://dev.to/coldtill/usdt-on-tron-and-the-energy-bill-what-sweeping-trc-20-deposits-really-costs-52ji</guid>
      <description>&lt;p&gt;If you take stablecoins, a lot of your customers will want to pay in USDT on Tron. Transfers are cheap for them and most exchanges support withdrawals on it. Then you build the part that moves those deposits somewhere safe, and you find out the cheap part was only cheap for the sender.&lt;/p&gt;

&lt;p&gt;This post is about that second step: getting USDT out of per-customer deposit addresses and into a wallet you control, and why the cost of doing it on Tron behaves differently from what you might expect if you've only done this on EVM chains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two resources, not one gas price
&lt;/h2&gt;

&lt;p&gt;On Ethereum-style chains you think in gas: a transaction uses some units, you pay a price per unit, done. Tron splits the cost into two resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth&lt;/strong&gt;, which pays for the bytes of the transaction. Every account gets a small free daily allowance, and a plain TRX transfer usually fits inside it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Energy&lt;/strong&gt;, which pays for running smart contract code. A USDT transfer is a call to the TRC-20 contract, so it needs energy, and quite a lot of it compared with a simple transfer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the account sending the transaction doesn't have enough energy, the network burns TRX from that account to cover the shortfall. That's the bit that surprises people. The fee isn't a fixed number you can read off a block explorer once and hard-code; it depends on how much energy the sender already has and on the network's current energy price.&lt;/p&gt;

&lt;p&gt;You can get energy three ways: burn TRX each time, stake (freeze) TRX to receive energy that regenerates, or rent it from someone who has staked. Renting is often the cheapest option for a business that sweeps irregularly, because you don't have to lock up capital to stake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sweeps make this worse
&lt;/h2&gt;

&lt;p&gt;A checkout that gives every customer their own deposit address is the right design. It makes payments easy to match and stops one customer's payment being confused with another's. But it means money lands in many small places, and every one of them needs a contract call to move.&lt;/p&gt;

&lt;p&gt;Some rough intuition, without pretending to exact numbers that change week to week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sweeping a deposit costs about the same whether it holds $5 or $5,000. The energy is spent on the contract call, not on the amount.&lt;/li&gt;
&lt;li&gt;So the fee as a share of the amount is brutal for small deposits and close to irrelevant for large ones.&lt;/li&gt;
&lt;li&gt;If you sweep every deposit as soon as it confirms, you pay the full cost on every small order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual answer is the same one you'd use on any chain: don't sweep immediately, sweep when it's worth it. Let deposits sit until the value justifies the cost, set a cap on what you're willing to pay, and batch the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the sweeper needs to decide
&lt;/h2&gt;

&lt;p&gt;When I sketch a sweep job on paper it ends up with four questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is there enough on this address to bother?&lt;/strong&gt; A per-network minimum, expressed in dollars, not token units.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What will it cost right now?&lt;/strong&gt; Estimate energy and bandwidth, then price any shortfall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is that under my cap?&lt;/strong&gt; A ceiling per sweep, so a spike in energy prices pauses sweeping instead of eating the margin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does the energy come from?&lt;/strong&gt; Burn, use staked energy, or rent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fourth question is the Tron-specific one. On EVM chains the gas has to come from somewhere too, usually a small hot wallet that tops up deposit addresses, but there's no rental market to think about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the hot wallet small and boring
&lt;/h2&gt;

&lt;p&gt;Whatever pays for sweeps is a hot wallet: its key has to be available to an automated process. That's a risk you want to shrink, not eliminate by pretending it doesn't exist.&lt;/p&gt;

&lt;p&gt;A few rules that hold up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The hot wallet only ever holds fuel. TRX for energy and bandwidth, ETH or the native coin for gas. Never customer funds.&lt;/li&gt;
&lt;li&gt;Only the process that sweeps (and, if you have one, the one that pays out) can unlock it. The web-facing API shouldn't be able to.&lt;/li&gt;
&lt;li&gt;Keep it encrypted at rest behind a passphrase, and keep the balance low enough that losing it would be annoying rather than serious.&lt;/li&gt;
&lt;li&gt;Alert when it runs low. A sweeper that silently stops because it ran out of TRX is a quiet way to end up with funds sitting on deposit addresses for weeks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the money is allowed to go matters more than the fee
&lt;/h2&gt;

&lt;p&gt;Fees are an operational cost. The destination is a security question. If a compromised server can change where sweeps go, every optimisation above is irrelevant.&lt;/p&gt;

&lt;p&gt;This is the design choice we made in Coldtill. On Ethereum-style chains, Tron and Solana, each deposit address is a contract that can only send to the merchant's cold wallet, so the sweeper can trigger a sweep but can't choose a different destination. The sweep rules are per network with fee caps, funds move in batches when the value justifies the gas, and on Tron the sweeper can rent energy instead of burning TRX. The dashboard also shows funds still sitting on deposit addresses until they're swept, which is the number you actually want to watch.&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%2Fni29p7y0krz6ep97jpf2.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%2Fni29p7y0krz6ep97jpf2.png" alt="Coldtill stats page, including funds still on deposit addresses" width="800" height="606"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you'd rather read the code than my description, the gateway is on GitHub: &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;. The product page with the full feature list is at &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;coldtill.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist if you're building this yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Track deposits in base units as integers, and value them in dollars only for the sweep threshold.&lt;/li&gt;
&lt;li&gt;Estimate energy before each Tron sweep and price the shortfall at the current rate.&lt;/li&gt;
&lt;li&gt;Decide up front whether you'll burn, stake or rent, and make the choice a setting, not a code change.&lt;/li&gt;
&lt;li&gt;Put a per-sweep fee cap in place and log every sweep that was skipped because of it.&lt;/li&gt;
&lt;li&gt;Fund sweeps from a small, separate hot wallet that never touches customer money.&lt;/li&gt;
&lt;li&gt;Make the sweep destination something the server can't change.&lt;/li&gt;
&lt;li&gt;Show "money not yet swept" somewhere a human will see it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tron USDT is popular for good reasons, and it's worth supporting. Just budget for the second transaction, not only the first.&lt;/p&gt;

</description>
      <category>cryptocurrency</category>
      <category>blockchain</category>
      <category>selfhosted</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Pricing in dollars, paid in crypto: decimals, rounding and the one-cent problem</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Fri, 13 Nov 2026 10:28:41 +0000</pubDate>
      <link>https://dev.to/coldtill/pricing-in-dollars-paid-in-crypto-decimals-rounding-and-the-one-cent-problem-42hf</link>
      <guid>https://dev.to/coldtill/pricing-in-dollars-paid-in-crypto-decimals-rounding-and-the-one-cent-problem-42hf</guid>
      <description>&lt;p&gt;One of the most common bug reports on a crypto checkout isn't about reorgs or webhooks. It's a customer who paid exactly what the screen told them and got an order stuck in "partially paid", short by a fraction of a cent.&lt;/p&gt;

&lt;p&gt;Nobody had stolen anything. The amount had just been through too many conversions: a USD price, a float in JavaScript, a token with six decimals, a wallet that rounded the display, and an exchange that shaved a little off for its own fee. Each step was "basically right". Together they produced an order a human had to look at.&lt;/p&gt;

&lt;p&gt;This post is about that boring layer: turning a price in dollars into an amount on chain, and deciding when the money that arrives is "the same" as the money you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store money as integers, in the unit the chain uses
&lt;/h2&gt;

&lt;p&gt;This is the rule everyone knows and still breaks. Don't hold token amounts in floats, not even briefly.&lt;/p&gt;

&lt;p&gt;Every asset has a smallest unit and a number of decimals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bitcoin: 8 decimals (satoshis)&lt;/li&gt;
&lt;li&gt;Ether and most EVM-native coins: 18 decimals (wei)&lt;/li&gt;
&lt;li&gt;USDC and USDT: usually 6 decimals, though check the contract on each chain rather than assuming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So an invoice for 49.99 USDC is &lt;code&gt;49990000&lt;/code&gt; base units. Store that integer. Compare integers. Only format it for humans at the edge.&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;// amounts in base units, as bigint&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toBaseUnits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decimalString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bigint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;decimalString&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"too many decimal places"&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="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whole&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;frac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"0"&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="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;toBaseUnits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"49.99"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 49990000n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your price comes in as a number from somewhere else, convert it to a decimal string with an explicit precision before it touches this function. &lt;code&gt;0.1 + 0.2&lt;/code&gt; is still &lt;code&gt;0.30000000000000004&lt;/code&gt; and it will still end up on someone's invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide who rounds, and in which direction
&lt;/h2&gt;

&lt;p&gt;Converting USD to a volatile coin always produces more digits than you want. 49.99 USD at some BTC rate is a long tail of satoshi fractions. You have to round, and the direction is a business decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Round up&lt;/strong&gt; and the customer occasionally pays a hair more than the sticker price. Nobody complains about a satoshi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round down&lt;/strong&gt; and you occasionally receive a hair less, which, depending on how strict your matching is, can turn into a partial payment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My preference is to round the requested amount up to the smallest unit, every time, and show the customer the exact rounded figure with a copy button. What they copy is what I compare against. That alone prevents a whole class of "I paid what it said" tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exact amounts beat "about this much"
&lt;/h2&gt;

&lt;p&gt;The checkout should never show a rounded-for-humans number as the thing to send. "0.00073 BTC" on screen while the system expects &lt;code&gt;73412&lt;/code&gt; sats is how you manufacture partial payments.&lt;/p&gt;

&lt;p&gt;Show the full amount. Make it copyable. Put it in the QR code or wallet link so the customer's wallet fills it in. &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt;'s hosted checkout does exactly this: exact amounts, copy buttons and wallet links, plus live confirmation progress once the transfer is seen.&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%2Fqvi2x4pzkp26ezljrxim.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%2Fqvi2x4pzkp26ezljrxim.png" alt="Hosted checkout showing the exact amount to send" width="732" height="1185"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The amount on screen is the amount the system matches. No "approximately".&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Stablecoins mostly make this go away (mostly)
&lt;/h2&gt;

&lt;p&gt;If you settle in USDC or USDT, the conversion question nearly disappears: one dollar is roughly one token and 49.99 is 49.99. The exchange-rate step is gone, so the rounding step is gone with it.&lt;/p&gt;

&lt;p&gt;Two things remain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decimals still differ.&lt;/strong&gt; A 6-decimal token and an 18-decimal token on another chain are not interchangeable integers. Normalise per asset, per chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exchanges and bridges take a slice.&lt;/strong&gt; A customer withdrawing from an exchange may see the fee taken from the amount instead of on top of it. That's not a rounding issue, it's a genuinely short payment, and your system should treat it as one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pick a tolerance and write it down
&lt;/h2&gt;

&lt;p&gt;Even with all of the above, you'll see payments that differ from the order by a trivially small amount. You need a rule for "close enough", and the rule needs to be explicit, small and tested.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://wordpress.org/plugins/coldtill-crypto-payment-gateway/" rel="noopener noreferrer"&gt;Coldtill WooCommerce plugin&lt;/a&gt; is a nice concrete example because its behaviour is documented on the listing: if the amount Coldtill reports doesn't match the order total, apart from a one-cent difference, the plugin adds an order note and leaves the status unchanged. The changelog even records the edge case being fixed, where a payment exactly one cent off had been left pending instead of marked paid. That's the kind of bug this whole post is about. The boundary condition is where it lives.&lt;/p&gt;

&lt;p&gt;A tolerance like that has a few properties I'd copy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it's tiny, so it can't hide a real shortfall,&lt;/li&gt;
&lt;li&gt;it's expressed in the pricing currency (cents), not in token units that change meaning between assets,&lt;/li&gt;
&lt;li&gt;anything outside it doesn't silently fail or silently pass. It leaves a note and waits for a human.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;TOLERANCE_CENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1n&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;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receivedCents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bigint&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="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;receivedCents&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;expectedCents&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="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="no"&gt;TOLERANCE_CENTS&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="no"&gt;TOLERANCE_CENTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"paid"&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="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"partial"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"overpaid"&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;Write tests for &lt;code&gt;-1&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;+1&lt;/code&gt; and &lt;code&gt;-2&lt;/code&gt;. The one that bites is always on the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep one source of truth for "paid"
&lt;/h2&gt;

&lt;p&gt;The last trap is having two systems that each think they know the answer. If your shop computes "paid" from the webhook body and your gateway computes it from the chain, sooner or later they'll disagree by a cent.&lt;/p&gt;

&lt;p&gt;Let the gateway decide, and have the shop act on that decision. Coldtill prices payments in US dollars and its watcher tracks fill state (paid, partially paid, cancelled) on your own server, then sends a signed webhook. The shop's job is to verify the signature and map the state, not to redo the maths. If you're curious how that's wired, the code for the self-hosted gateway is on GitHub at &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;github.com/Coldtill/self-hosted-crypto-payment-gateway&lt;/a&gt;.&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%2F0pjfp8lf9zv2bmhqbrc0.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%2F0pjfp8lf9zv2bmhqbrc0.png" alt="Payments list with fill states" width="800" height="456"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Payments with their state. Partial payments stay visible instead of vanishing into "failed".&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Integers in base units, never floats.&lt;/li&gt;
&lt;li&gt;Round the requested amount up, and show exactly what you'll match.&lt;/li&gt;
&lt;li&gt;Normalise decimals per asset and per chain.&lt;/li&gt;
&lt;li&gt;Have a small, written, tested tolerance in the pricing currency.&lt;/li&gt;
&lt;li&gt;One system decides "paid". Everything else listens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is clever. It's just the part of a crypto checkout that decides whether a real person gets an email saying their order is on its way, so it's worth getting boring and right.&lt;/p&gt;

</description>
      <category>cryptocurrency</category>
      <category>webdev</category>
      <category>payments</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your own node or someone else's RPC? What a crypto payment watcher should trust</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Wed, 11 Nov 2026 09:53:31 +0000</pubDate>
      <link>https://dev.to/coldtill/your-own-node-or-someone-elses-rpc-what-a-crypto-payment-watcher-should-trust-135n</link>
      <guid>https://dev.to/coldtill/your-own-node-or-someone-elses-rpc-what-a-crypto-payment-watcher-should-trust-135n</guid>
      <description>&lt;p&gt;Every crypto checkout has a part nobody screenshots: the process that watches the chain and decides an invoice is paid. It's the least glamorous piece of the stack and the one that decides whether you ship goods for money you never received.&lt;/p&gt;

&lt;p&gt;When I talk to people building their own checkout, the first shortcut they reach for is a hosted RPC endpoint. Sign up, paste an API key, poll &lt;code&gt;eth_getLogs&lt;/code&gt; or a block explorer API, done. For a prototype that's fine. For the thing that flips orders to "paid", it's worth slowing down and asking what you are actually trusting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the watcher really decides
&lt;/h2&gt;

&lt;p&gt;Strip it down and the watcher answers three questions for every invoice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did a transfer of the right asset arrive at the right address?&lt;/li&gt;
&lt;li&gt;Is it big enough to cover the invoice (or is it partial, or over)?&lt;/li&gt;
&lt;li&gt;Is it buried deep enough that it won't disappear in a reorg?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three answers come from whatever node you are asking. If that node is wrong, slow, rate-limited, or lying, your order state is wrong. Your code can be perfect and still credit a payment that isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure modes of borrowed data
&lt;/h2&gt;

&lt;p&gt;None of these are exotic. I've seen each of them in someone's support inbox:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lag.&lt;/strong&gt; A hosted endpoint behind a load balancer can serve you a block height a few blocks behind the tip from one backend and ahead from another. Your confirmation counter goes up, then down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits at the worst moment.&lt;/strong&gt; Busy market, lots of checkouts, and your free tier starts returning 429s. The watcher either stalls (customers stare at a spinner) or, worse, a badly written retry treats "no data" as "no payment" and expires the invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different views of the same chain.&lt;/strong&gt; Two providers can disagree briefly about the head during a reorg. If you mix them, you get both versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Someone else's outage is now your outage.&lt;/strong&gt; Your checkout is up, your database is up, and nobody can pay because a third party is having a bad afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy.&lt;/strong&gt; Every address you query tells the provider which addresses belong to your shop and when orders come in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this means hosted RPC is evil. It means it's an input you should treat as a hint, not as the ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sensible split: settle on what you verify
&lt;/h2&gt;

&lt;p&gt;The approach I like, and the one &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt; takes, is simple to say: you run nodes for the networks you settle on, and the order is credited only when your own nodes see the funds arrive with full confirmations.&lt;/p&gt;

&lt;p&gt;That sentence has a consequence people miss. You don't need a node for every coin you accept. Coldtill takes Ethereum, Base, Polygon, BNB, Tron, Solana and Bitcoin directly, and with auto-convert switched on it can accept 200+ more tokens on 35+ chains through NEAR Intents, which swaps the payment and delivers USDC or USDT to your cold wallet. For those swapped payments, the swap provider's progress reports are treated as hints. The order is only credited once your own node sees the stablecoin land in your cold wallet. You run a node for the chain you get paid on, not for every chain a customer might pay from.&lt;/p&gt;

&lt;p&gt;That's the general rule I'd apply to any design, with or without Coldtill: &lt;strong&gt;the chain you settle on is the chain you verify yourself.&lt;/strong&gt; Everything upstream of it is a status bar.&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%2Fc9b8p6vjtkxx5len3ut8.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%2Fc9b8p6vjtkxx5len3ut8.png" alt="Payments list in the Coldtill dashboard" width="800" height="456"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The payments list. An entry only moves to paid when the watcher on your own server has seen enough confirmations.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What "enough confirmations" means in practice
&lt;/h2&gt;

&lt;p&gt;Once you own the data, you still have to decide when to believe it. A few rules that hold up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count from your own head, every time.&lt;/strong&gt; Confirmations are &lt;code&gt;your_tip - tx_block + 1&lt;/code&gt;, recomputed on each new block, never cached as a number that only goes up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect the count to go backwards.&lt;/strong&gt; A reorg can drop a transaction out of the canonical chain. Your state machine needs a path from "confirming" back to "waiting", and nothing downstream (fulfilment emails, licence keys) should fire before "confirmed".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick thresholds per network.&lt;/strong&gt; A fast chain with frequent short reorgs and Bitcoin with ten-minute blocks shouldn't share one number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate "seen" from "paid" in the UI.&lt;/strong&gt; Customers calm down a lot when the checkout says "we see your payment, 2 of N confirmations" instead of nothing. Coldtill's hosted checkout shows live confirmation progress for exactly this reason.&lt;/li&gt;
&lt;/ul&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%2Firvn1zbd7xzaqyc9ndry.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%2Firvn1zbd7xzaqyc9ndry.png" alt="Hosted checkout waiting for payment" width="732" height="1185"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The customer side: exact amount, QR code, and confirmation progress once the transfer is seen.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Then tell your shop, once
&lt;/h2&gt;

&lt;p&gt;The watcher's last job is telling the shop. Coldtill sends a signed webhook with replay protection when a payment fills, partially fills or is cancelled. On your side that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;verify the signature and timestamp before reading the body,&lt;/li&gt;
&lt;li&gt;make the handler idempotent (the same payment arriving twice must not ship twice),&lt;/li&gt;
&lt;li&gt;and if you can, re-check status when the customer lands on your return page, because the browser sometimes beats the webhook.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote more about the ordering problem in an earlier post on confirmations, reorgs and out-of-order webhooks, so I won't repeat it here.&lt;/p&gt;
&lt;h2&gt;
  
  
  The cost side, honestly
&lt;/h2&gt;

&lt;p&gt;Running your own node is real work. You need disk, you need to watch sync status, and you need an alert when it falls behind. For a single small shop that might feel like overkill compared to a free API key.&lt;/p&gt;

&lt;p&gt;My answer is that it's the same trade you make with your database. You could rent a shared one where you can't see the logs, or you can run a boring Postgres you understand. For the component that decides whether money arrived, I want the boring thing I can inspect.&lt;/p&gt;

&lt;p&gt;If you'd like to see how the watcher fits next to the other processes (API, sweeper, payouts) in a Docker install, the &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;Coldtill gateway source on GitHub&lt;/a&gt; is the place to start. The free edition installs with one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -fsSL https://coldtill.com/install.sh &lt;span class="o"&gt;|&lt;/span&gt; sudo bash -s -- --edition free
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever you build on, decide early which data you verify and which you merely accept. It's much easier to design that in than to bolt it on after the first "but the explorer said it was paid" ticket.&lt;/p&gt;

</description>
      <category>cryptocurrency</category>
      <category>selfhosted</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Updating a self-hosted payment gateway without losing a payment</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Mon, 09 Nov 2026 10:15:16 +0000</pubDate>
      <link>https://dev.to/coldtill/updating-a-self-hosted-payment-gateway-without-losing-a-payment-326m</link>
      <guid>https://dev.to/coldtill/updating-a-self-hosted-payment-gateway-without-losing-a-payment-326m</guid>
      <description>&lt;p&gt;Running your own payment software means you are also the person who updates it. Nobody pushes a new version to you at 3 a.m. on a hosted dashboard. That is the trade, and for most shops it is a good one, but it deserves a plan, because "I'll update it when I get round to it" is how a checkout ends up three versions behind with a known bug.&lt;/p&gt;

&lt;p&gt;This is how I think about updating a &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt; install. Coldtill is a self-hosted crypto payment gateway that runs on a Linux server with Docker. Most of what follows is general release hygiene for any payment service you operate. Where I mention a Coldtill-specific detail, it comes from the public site and the &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;. For the exact update commands, use the docs that ship with your edition rather than my paraphrase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things you should know before you start
&lt;/h2&gt;

&lt;p&gt;First, the only call home Coldtill makes is an update check, and you can turn it off. There is no licence server and no kill switch, so nothing stops working if you decide not to update. That is good for independence and bad for complacency. If you switch the check off, you now own the job of noticing new releases yourself.&lt;/p&gt;

&lt;p&gt;Second, security fixes are free for every edition, always. So there is no "we can't afford the new version" excuse for staying on a build with a known hole. The question is only when you can safely take the update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick your window by looking at your own payments
&lt;/h2&gt;

&lt;p&gt;A payment gateway is different from a blog. A deploy that restarts it can land in the middle of a customer's transaction. The money itself is not at risk, because deposit addresses live on chain and keep existing whether or not your server is up. What you can lose is &lt;em&gt;attention&lt;/em&gt;: a payment arrives, nobody is watching, and the order state lags.&lt;/p&gt;

&lt;p&gt;So before updating, look at when payments actually come in. The dashboard's payments and stats pages show you this. If 80% of your orders land between 14:00 and 22:00 in your customers' time zone, do not update at 17:00 because it suits you. Pick the quietest few hours and write them down.&lt;br&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%2F4jku427eiodup44245vi.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%2F4jku427eiodup44245vi.png" alt="Dashboard stats showing payment activity over time" width="800" height="606"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist I'd actually follow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check for pending payments.&lt;/strong&gt; Open the payments list and look for anything not yet confirmed. If something is mid-confirmation, wait. It is a few minutes, not a reason to rush.&lt;br&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%2Fie14bj3z3gp7c1yuqxrl.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%2Fie14bj3z3gp7c1yuqxrl.png" alt="Coldtill dashboard payments list showing pending and confirmed payments" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Take a backup you have actually tested.&lt;/strong&gt; Not "I think the cron job runs". Confirm there is a recent one and that you know where it is. If you have not rehearsed a restore, do that on a scratch VPS first. It is cheaper than learning the procedure under stress.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read the release notes.&lt;/strong&gt; Look for anything touching webhooks, the API, or settings you rely on. A shop calling the REST API cares about request and response shapes. A WooCommerce store cares about the plugin staying compatible with the server version.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Update staging first, if you have one.&lt;/strong&gt; A second small install with a testnet-style configuration is enough. You are not testing the chain, you are testing that your shop still creates a payment and still gets a signed webhook.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Update production.&lt;/strong&gt; Then do not walk away.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run one real small payment end to end&lt;/strong&gt; (see below).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What to verify afterwards
&lt;/h2&gt;

&lt;p&gt;"The containers are up" is not the same as "payments work". After an update I check, in this order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The dashboard loads and shows the same projects, wallets and API keys as before.&lt;/li&gt;
&lt;li&gt;A fresh payment from my shop returns a checkout link, and the checkout page renders with the right branding.&lt;/li&gt;
&lt;li&gt;A small payment confirms and my shop receives the signed webhook. Webhooks are signed and carry replay protection, so a webhook sent with a wrong timestamp or signature is rejected. If you suddenly see rejections, check the clock on your shop's server before you blame the update.&lt;/li&gt;
&lt;li&gt;The order in the shop moves to its paid state exactly once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the one that matters most. If your handler is idempotent (I wrote about the out-of-order webhook problem in an earlier post), a replay or a retry after your update will not double-fulfil an order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch the plugin side too
&lt;/h2&gt;

&lt;p&gt;If you use the &lt;a href="https://wordpress.org/plugins/coldtill-crypto-payment-gateway/" rel="noopener noreferrer"&gt;WordPress plugin&lt;/a&gt; for WooCommerce, you now have two things to keep current: the Coldtill server and the plugin. The plugin page lists a changelog, and it is worth reading before you click update in WordPress. The plugin needs WordPress 6.4 or higher and PHP 8.1 or higher, so a hosting plan stuck on an old PHP will block you before Coldtill ever does. Also note the gateway only appears at checkout when the store currency is USD, which is the first thing to check if it vanishes after a settings change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling back
&lt;/h2&gt;

&lt;p&gt;Have an answer to "how do I go back?" before you need it. Usually that means the backup from step 2 and the previous version noted down. If something looks wrong and you cannot explain it in ten minutes, going back and investigating at leisure is better than debugging live on a checkout customers are using.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring conclusion
&lt;/h2&gt;

&lt;p&gt;Self-hosting a payment gateway does not mean constant maintenance. It means a small, predictable routine: check for releases on a schedule, update in a quiet window, verify with a real payment, and keep a backup you trust. Do that monthly and it will take you less time than reading this post.&lt;/p&gt;

&lt;p&gt;If you want to look at what you'd be running, the repo is at &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;, and the live demo dashboard linked from coldtill.com has made-up data you can click through without installing anything.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>cryptocurrency</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Integrating a self-hosted crypto gateway without a plugin: the REST API path</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Sat, 07 Nov 2026 10:13:31 +0000</pubDate>
      <link>https://dev.to/coldtill/integrating-a-self-hosted-crypto-gateway-without-a-plugin-the-rest-api-path-1cag</link>
      <guid>https://dev.to/coldtill/integrating-a-self-hosted-crypto-gateway-without-a-plugin-the-rest-api-path-1cag</guid>
      <description>&lt;p&gt;Most crypto payment write-ups assume you run WooCommerce. Paste a plugin, flip a switch, done. That works when the cart &lt;em&gt;is&lt;/em&gt; WooCommerce. It falls apart when the cart is a Next.js storefront, a SaaS billing page, a custom invoice tool, or a stack you wrote yourself five years ago and are not rewriting for a payment vendor.&lt;/p&gt;

&lt;p&gt;Those shops still need a checkout. They just need it over HTTP.&lt;/p&gt;

&lt;p&gt;This post is the REST path into &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt;: create a payment, send the customer to a checkout link, get a signed webhook when it fills, and keep your own order state machine. No plugin required. The same API is what every Coldtill plugin (WooCommerce, PrestaShop, Magento, WHMCS, EmDash, DashCommerce) speaks under the hood — you are just calling it from your own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are integrating with
&lt;/h2&gt;

&lt;p&gt;Coldtill is a self-hosted crypto payment gateway. You install it on a Linux box with Docker; the stack lands under &lt;code&gt;/opt/coldtill&lt;/code&gt; with PostgreSQL, Caddy, and four processes: &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;watcher&lt;/code&gt;, &lt;code&gt;sweeper&lt;/code&gt;, &lt;code&gt;payouts&lt;/code&gt;. Free edition install:&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;-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;There is no licence server and no merchant account at Coldtill. The money goes to &lt;em&gt;your&lt;/em&gt; cold wallet. Deposit contracts on Ethereum-style chains, Tron and Solana can only send to that cold wallet; Bitcoin deposit addresses come from your own wallet pubkey. The API process holds no keys. Source and releases are on the &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;self-hosted crypto payment gateway&lt;/a&gt; repo if you want to read the Compose file before you run anything.&lt;/p&gt;

&lt;p&gt;Direct networks cover Ethereum-style (Ethereum, Base, Polygon, BNB), Tron, Solana, Bitcoin, plus USDC/USDT on those rails. Across the product that expands to 200+ coins on 35+ chains when optional auto-convert is on — that path is a separate topic; the REST flow below is the same either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The integration shape
&lt;/h2&gt;

&lt;p&gt;Strip away the plugins and you are left with four moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a payment&lt;/strong&gt; — &lt;code&gt;POST /api/v1/payment&lt;/code&gt; with amount (USD), customer id, and a return URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect the customer&lt;/strong&gt; — the response includes a checkout link on your Coldtill host (or a custom checkout domain on the project).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receive a signed webhook&lt;/strong&gt; — when the payment fills, partially fills, or cancels, Coldtill POSTs to your endpoint with a signature and replay protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optionally recheck on return&lt;/strong&gt; — when the customer hits your return page, ask the API for the payment status again in case the webhook raced the browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the whole contract. Everything else (which coin they pick, confirmations, sweeps to cold wallet) is Coldtill's problem on your server.&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%2F1alc1rjrl2qwbbunj7al.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%2F1alc1rjrl2qwbbunj7al.png" alt="Payments dashboard" width="800" height="456"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Payments created over the API show up in the dashboard like any other: amount, status, network, and whether they have been swept yet.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Creating a payment
&lt;/h2&gt;

&lt;p&gt;You need a project API key from the Coldtill dashboard. Keys are scoped to one project (one shop surface: wallets, branding, webhooks). Create the payment against your Coldtill base URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/v1/payment
Authorization: Bearer &amp;lt;project-api-key&amp;gt;
Content-Type: application/json

{
  "amount": "49.00",
  "currency": "USD",
  "customer_id": "usr_1842",
  "order_id": "inv_9911",
  "return_url": "https://app.example.com/billing/crypto/return"
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Field names in your client may follow the OpenAPI schema exactly — Coldtill ships OpenAPI 3.1 and a TypeScript SDK generated from it, so you do not have to hand-roll types. The important bits are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Amount in USD.&lt;/strong&gt; Coldtill prices payments in dollars. Your storefront can display whatever you like; the invoice Coldtill creates is USD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer id.&lt;/strong&gt; Something stable in &lt;em&gt;your&lt;/em&gt; system (user id, account id). Coldtill uses it to keep deposit addresses consistent per customer where that model applies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order / reference id.&lt;/strong&gt; Whatever you need to map the webhook back to an invoice, subscription period, or cart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return URL.&lt;/strong&gt; Where the checkout "done" button sends the browser. Do not treat arrival here as paid — treat it as a prompt to recheck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The response gives you a payment id and a &lt;strong&gt;checkout URL&lt;/strong&gt;. Send the customer there. They pick a network and coin, see the exact amount and deposit address, and pay.&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%2Fd9ypdv5f6y2xhvma8hnx.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%2Fd9ypdv5f6y2xhvma8hnx.png" alt="Checkout coin and network picker" width="732" height="851"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The hosted checkout is yours: project branding, optional custom domain, coin and network picker. Your app only created the payment and redirected.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You do not poll the blockchain from the storefront. The &lt;code&gt;watcher&lt;/code&gt; on your Coldtill box does that. Your app waits for the webhook (and optionally rechecks on return).&lt;/p&gt;
&lt;h2&gt;
  
  
  Signed webhooks without getting burned
&lt;/h2&gt;

&lt;p&gt;Webhooks are where custom integrations usually get clever and then get hurt. Coldtill signs each delivery and includes replay protection. Practical rules that have held up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify the signature&lt;/strong&gt; with the project's webhook secret before you touch order state. Reject anything that fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect replay protection.&lt;/strong&gt; A replayed &lt;code&gt;payment.filled&lt;/code&gt; should be a no-op, not a second credit. Keep an idempotency key or event id and short-circuit duplicates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map statuses, don't invent them.&lt;/strong&gt; Filled means enough confirmed value to cover the invoice under your rules. Partial and cancelled are first-class; handle them explicitly so support is not inventing policy in Slack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship or unlock from the webhook, not from the return page.&lt;/strong&gt; The return page is a UX nicety. Networks are slow; browsers are impatient. The webhook is the source of truth; a return-page GET that rechecks the payment is a backup when the user stares at a spinner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal receiver sketch (shape only — use the SDK or OpenAPI types in real code):&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="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="s2"&gt;/webhooks/coldtill&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="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="nf"&gt;verifyColdtillSignature&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;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="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;status&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="nf"&gt;end&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;event&lt;/span&gt; &lt;span class="o"&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="k"&gt;if &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;alreadyProcessed&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;id&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;status&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="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;switch &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="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payment.filled&lt;/span&gt;&lt;span class="dl"&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;markInvoicePaid&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order_id&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payment.partial&lt;/span&gt;&lt;span class="dl"&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;flagPartial&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order_id&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;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payment.cancelled&lt;/span&gt;&lt;span class="dl"&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;releaseReservation&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&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;rememberEvent&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;id&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;status&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="nf"&gt;end&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;Point the project's webhook URL at that endpoint in the Coldtill dashboard. Each project has its own URL and signing secret, so a SaaS billing surface and a side store can land on different receivers without sharing secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenAPI and the TypeScript SDK
&lt;/h2&gt;

&lt;p&gt;Hand-writing &lt;code&gt;fetch&lt;/code&gt; calls is fine for a spike. For anything that lives more than a weekend, use the OpenAPI 3.1 document Coldtill exposes and the TypeScript SDK built from it. You get typed request/response shapes for creating payments, fetching a payment by id (the return-page recheck), listing, and the webhook payload shapes your verifier should expect.&lt;/p&gt;

&lt;p&gt;That also keeps you honest when you upgrade the Coldtill box: regenerate or bump the SDK, fix the type errors, ship. Plugins do the same dance behind a nicer UI; custom stacks get the raw contract.&lt;/p&gt;

&lt;p&gt;Roles on the Coldtill side matter here. Give the engineer who owns the integration a &lt;strong&gt;developer&lt;/strong&gt; role (API keys, webhooks) and keep &lt;strong&gt;admin&lt;/strong&gt; for people who can change wallets and approve payouts. &lt;strong&gt;Read-only&lt;/strong&gt; is for whoever watches the dashboard without touching keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fitting it into a real stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Custom storefront / headless cart
&lt;/h3&gt;

&lt;p&gt;On "Pay with crypto", create the payment server-side with the project API key (never from the browser), redirect to the checkout URL, and let the webhook flip the order to paid. Your cart never sees a private key. Your cart never talks to a chain RPC.&lt;/p&gt;

&lt;h3&gt;
  
  
  SaaS billing / invoices
&lt;/h3&gt;

&lt;p&gt;Same pattern, different nouns. Invoice id becomes &lt;code&gt;order_id&lt;/code&gt;. When &lt;code&gt;payment.filled&lt;/code&gt; lands, extend the subscription period or mark the invoice settled. Partials stay open with a clear "underpaid — send the remainder to the same address" state if that matches how you operate; otherwise cancel and issue a fresh invoice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mixed fleet
&lt;/h3&gt;

&lt;p&gt;One Coldtill install can host many projects. WooCommerce shops use the WordPress plugin; the weird internal billing tool uses REST. Same server under &lt;code&gt;/opt/coldtill&lt;/code&gt;, different project API keys and webhook secrets. The &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt; is the place to confirm process layout and Compose services if you are sizing the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API deliberately cannot do
&lt;/h2&gt;

&lt;p&gt;A few boundaries are worth knowing before you design around the wrong assumption:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The API cannot move customer deposits to an arbitrary address.&lt;/strong&gt; On EVM-style, Tron and Solana, deposit contracts are locked to your cold wallet. Sweeps only go there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The API holds no keys.&lt;/strong&gt; Gas for sweeps and the hot wallet used for payouts/refunds are unlocked by the &lt;code&gt;sweeper&lt;/code&gt; and &lt;code&gt;payouts&lt;/code&gt; processes behind your passphrase — not by a REST call with an API key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payouts are a person-shaped flow.&lt;/strong&gt; Address book, 2FA, and for amounts over your limits an admin plus a fresh 2FA code plus an emailed OTP. Do not plan an unattended "refund any address the customer pastes" endpoint on top of Coldtill; that is not what the till is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editions change sweep fee, not API surface.&lt;/strong&gt; Free is 2%, Standard 1%, Pro 0% — one-time price, no licence check on the box. Your integration code does not change between editions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A short checklist before you go live
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Coldtill installed, project created, cold wallets set, hot wallet topped up for gas.&lt;/li&gt;
&lt;li&gt;Project API key created; webhook URL and secret configured; signature verification tested with a deliberate bad signature.&lt;/li&gt;
&lt;li&gt;Create-payment → checkout → testnet (or small mainnet) payment → webhook received once → return-page recheck agrees.&lt;/li&gt;
&lt;li&gt;Partial and cancel paths exercised so support has a runbook.&lt;/li&gt;
&lt;li&gt;Idempotency proven: replay the same webhook, confirm no double credit.&lt;/li&gt;
&lt;li&gt;API key stored as a server secret; browser never sees it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;If you do not have a plugin for your stack, you do not need one. &lt;code&gt;POST /api/v1/payment&lt;/code&gt;, redirect to the checkout link, verify signed webhooks with replay protection, recheck on return. OpenAPI 3.1 and the TypeScript SDK keep the contract typed. The gateway still settles to your cold wallet on your server; your app just drives the payment lifecycle over HTTP.&lt;/p&gt;

&lt;p&gt;Docs, pricing and the install one-liner are on &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;coldtill.com&lt;/a&gt;. The &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;self-hosted crypto payment gateway&lt;/a&gt; repo has the source if you want to read before you curl.&lt;/p&gt;

</description>
      <category>api</category>
      <category>cryptocurrency</category>
      <category>webdev</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Many shops, one server: running several crypto checkouts from a single Coldtill install</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Thu, 05 Nov 2026 10:09:23 +0000</pubDate>
      <link>https://dev.to/coldtill/many-shops-one-server-running-several-crypto-checkouts-from-a-single-coldtill-install-1ka7</link>
      <guid>https://dev.to/coldtill/many-shops-one-server-running-several-crypto-checkouts-from-a-single-coldtill-install-1ka7</guid>
      <description>&lt;p&gt;If you run more than one store, the usual crypto-payments story gets expensive fast. Each brand wants its own checkout domain, its own webhook endpoint, its own cold wallet and its own API key. Hosted processors usually solve that with another merchant account and another cut of every sale. Self-hosting solves it with another container… unless the gateway was built to keep shops apart inside one install.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt; is built that way. One Linux box, one Docker stack, as many &lt;em&gt;projects&lt;/em&gt; as the business needs. Each project is a shop: its own wallets, API keys, webhooks, branding and checkout domain. The rest of this post is how that model actually works day to day, and the few places where people get tangled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why projects exist
&lt;/h2&gt;

&lt;p&gt;A "project" is Coldtill's word for one business surface. Think of it as the unit that a customer recognises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its own store name, logo, accent colour and light or dark theme on the hosted checkout&lt;/li&gt;
&lt;li&gt;Its own checkout host, such as &lt;code&gt;pay.brand-a.com&lt;/code&gt; or &lt;code&gt;pay.brand-b.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Its own cold wallets and deposit wallets per chain&lt;/li&gt;
&lt;li&gt;Its own API keys and webhook endpoints&lt;/li&gt;
&lt;li&gt;Its own payment history, customers and deposits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is &lt;em&gt;not&lt;/em&gt; duplicated is the server: PostgreSQL, Caddy, and the four Coldtill processes (&lt;code&gt;api&lt;/code&gt;, &lt;code&gt;watcher&lt;/code&gt;, &lt;code&gt;sweeper&lt;/code&gt;, &lt;code&gt;payouts&lt;/code&gt;) stay shared. That matters for ops. You back up one database, watch one set of containers, and upgrade once.&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%2Fvx1fw3brmrvrdsh4uy7o.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%2Fvx1fw3brmrvrdsh4uy7o.png" alt="Coldtill dashboard overview" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One install, several projects. The dashboard treats each shop as its own surface while the stack underneath is shared.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The design choice behind this is the same one as the rest of Coldtill: the money never belongs to the server. Splitting shops is about isolation of &lt;em&gt;configuration and attribution&lt;/em&gt;, not about spinning up a new place that holds customer funds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays separate (and why)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Wallets
&lt;/h3&gt;

&lt;p&gt;Each project points at its own cold wallet addresses. On Ethereum-style chains, Tron and Solana, deposit contracts for that project can only send to &lt;em&gt;that&lt;/em&gt; project's cold wallet. Bitcoin deposit addresses come from the project's own wallet pubkey. Mixing brands into one cold wallet is possible in the sense that nothing stops you pasting the same address twice, but it's usually a bookkeeping mistake. Keep them separate and your accountant will thank you later.&lt;/p&gt;

&lt;p&gt;Sweep behaviour is also per network and per project rules: funds move in batches when the value justifies the gas, with fee caps, and Tron can rent energy instead of burning TRX. A quiet brand doesn't get swept on the same schedule as a busy one just because they share a host.&lt;/p&gt;

&lt;h3&gt;
  
  
  API keys
&lt;/h3&gt;

&lt;p&gt;Create a project API key under &lt;strong&gt;API keys&lt;/strong&gt; for each shop. The key scopes calls to that project. If a plugin on Brand A's WooCommerce store leaks its key, Brand B's payments, webhooks and wallets are not reachable with it. Rotate per shop when someone leaves, without touching the others.&lt;/p&gt;

&lt;h3&gt;
  
  
  Webhooks
&lt;/h3&gt;

&lt;p&gt;Each project has its own webhook URL and signing secret. Brand A's store gets &lt;code&gt;payment.filled&lt;/code&gt; events for Brand A only. Replay protection and signature checks are the same mechanism everywhere; the secret is not shared across projects. That also means you can point Brand A at a staging receiver and Brand B at production without rewriting anything global.&lt;/p&gt;

&lt;h3&gt;
  
  
  Branding and checkout domain
&lt;/h3&gt;

&lt;p&gt;Customers notice this first. The checkout shows the shop's name and logo, not Coldtill's. Support email, help URL and a short message (up to 280 characters) are per project. The checkout can sit on the shop's own domain, so the address bar matches the store the customer just left. Two brands on one server look like two brands, which is the point.&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%2Fqs6dgjf9q0n45vgauwgx.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%2Fqs6dgjf9q0n45vgauwgx.png" alt="Coldtill dashboard stats" width="800" height="606"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Stats stay scoped to the project you're looking at: revenue, completion rate and where funds still sit on deposit addresses.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Team roles across shops
&lt;/h2&gt;

&lt;p&gt;Coldtill has three roles: &lt;strong&gt;admin&lt;/strong&gt;, &lt;strong&gt;developer&lt;/strong&gt; and &lt;strong&gt;read-only&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Admin&lt;/strong&gt; — full control: wallets, payouts, team, project settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer&lt;/strong&gt; — API keys, webhooks, integration work; not the person approving large payouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only&lt;/strong&gt; — payments, customers, deposits and stats for reporting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a multi-shop install this maps cleanly onto real teams. A contractor wiring Magento for Brand B gets developer on that project and nothing on Brand A. Finance gets read-only everywhere. Admins stay few. Large payouts still need an admin, a fresh two-factor code and an emailed one-time code, on top of the address book and hourly/daily limits — that gate doesn't loosen just because you added a second project.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical layout
&lt;/h2&gt;

&lt;p&gt;Here's a layout that has held up for people running a couple of stores plus a SaaS billing surface:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Checkout domain&lt;/th&gt;
&lt;th&gt;Integrates with&lt;/th&gt;
&lt;th&gt;Cold wallet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brand A shop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pay.branda.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WooCommerce plugin&lt;/td&gt;
&lt;td&gt;Hardware wallet A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand B shop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pay.brandb.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PrestaShop / Magento&lt;/td&gt;
&lt;td&gt;Hardware wallet B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SaaS invoices&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pay.saas.example&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;REST API + TypeScript SDK&lt;/td&gt;
&lt;td&gt;Company treasury&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One install at &lt;code&gt;/opt/coldtill&lt;/code&gt;, one Caddy, DNS for each checkout host pointed at the same box. Plugins for WooCommerce, PrestaShop, Magento, WHMCS, EmDash and DashCommerce all speak the same REST API and signed webhooks; the difference is which project API key you paste in.&lt;/p&gt;

&lt;p&gt;Install itself is still the Free edition one-liner if you're starting fresh:&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;-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 lands the stack under &lt;code&gt;/opt/coldtill&lt;/code&gt; (PostgreSQL, Caddy, &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;watcher&lt;/code&gt;, &lt;code&gt;sweeper&lt;/code&gt;, &lt;code&gt;payouts&lt;/code&gt;). There's no licence server and no approval queue. Source and releases live on the &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;self-hosted crypto payment gateway&lt;/a&gt; repo if you want to read the Compose file before you run anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a payment stays in its lane
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The shop calls &lt;code&gt;POST /api/v1/payment&lt;/code&gt; with its project API key and gets a checkout link for that project.&lt;/li&gt;
&lt;li&gt;The customer pays to their own permanent deposit address for that project/chain family.&lt;/li&gt;
&lt;li&gt;The watcher confirms, handles reorgs and fires the project's signed webhook.&lt;/li&gt;
&lt;li&gt;Later the sweeper moves funds to &lt;em&gt;that&lt;/em&gt; project's cold wallet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing in that path consults another project's wallets or keys. Attribution is by project and by &lt;code&gt;customerID&lt;/code&gt; inside the project: each customer has at most one open payment, and deposits on their address credit that payment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bite when you add a second shop
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Same cold wallet pasted twice.&lt;/strong&gt; Technically fine, operationally messy. Sweeps from two brands land in one place with no label on chain. Prefer separate cold wallets and let the dashboard show each project's unswept total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhook URL copied from the wrong project.&lt;/strong&gt; The signature will fail or the events will update the wrong store. When you set up a plugin, copy the webhook URL and secret from the project that matches the API key — both are shown in that project's settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One shared "ops" API key.&lt;/strong&gt; Don't. Issue a key per integration. Revoking a compromised Magento key should not mean regenerating every WooCommerce install you run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting checkout DNS.&lt;/strong&gt; Each project can use its own host. If Brand B still serves checkout under Brand A's domain, customers notice, and so do browsers when cookies and HSTS enter the picture. Add the host in the project, put DNS on it, let Caddy terminate TLS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assuming stats are global.&lt;/strong&gt; Open the wrong project and the dashboard looks empty. Switch project first, then panic.&lt;/p&gt;

&lt;h2&gt;
  
  
  When one server is enough (and when it isn't)
&lt;/h2&gt;

&lt;p&gt;One install is enough when the shops trust the same ops team, share a threat model and can share upgrade windows. It's the common case for a founder with two brands, or an agency hosting a handful of client checkouts they operate.&lt;/p&gt;

&lt;p&gt;Split onto another server when the trust boundary changes: a client who must hold their own &lt;code&gt;.env&lt;/code&gt; and database, a region with different retention rules, or a shop whose downtime budget can't ride on someone else's upgrade. Coldtill doesn't charge per server — editions differ by sweep fee (Free 2%, Standard 1%, Pro 0%), not by how many boxes you run — so splitting for isolation is an ops choice, not a pricing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do on day one of a second shop
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create the project, set name/logo/colours and the checkout domain.&lt;/li&gt;
&lt;li&gt;Attach cold wallets and confirm the first derived deposit address against your own wallet UI.&lt;/li&gt;
&lt;li&gt;Create a project API key and a webhook endpoint aimed at that shop only.&lt;/li&gt;
&lt;li&gt;Grant the integrator developer, not admin.&lt;/li&gt;
&lt;li&gt;Take a testnet payment end to end before mainnet DNS goes live.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to click around before installing, &lt;a href="https://demo.coldtill.com" rel="noopener noreferrer"&gt;demo.coldtill.com&lt;/a&gt; is a live dashboard with made-up data. For the real thing, the install command and docs are on &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;coldtill.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One server can absolutely run several crypto checkouts. The work is making sure each shop keeps its own wallets, keys, webhooks and face — and that the people with admin on any of them stay few.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>selfhosted</category>
      <category>docker</category>
      <category>cryptocurrency</category>
    </item>
    <item>
      <title>What to back up (and watch) when you self-host a crypto payment gateway</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Tue, 03 Nov 2026 10:12:52 +0000</pubDate>
      <link>https://dev.to/coldtill/what-to-back-up-and-watch-when-you-self-host-a-crypto-payment-gateway-4ade</link>
      <guid>https://dev.to/coldtill/what-to-back-up-and-watch-when-you-self-host-a-crypto-payment-gateway-4ade</guid>
      <description>&lt;p&gt;Most self-hosting advice about backups boils down to "dump the database somewhere". For a crypto payment gateway that's necessary, but it isn't the whole story, and the parts people skip are the ones that hurt.&lt;/p&gt;

&lt;p&gt;Here's the thing that took me a while to see clearly: on a non-custodial gateway, a dead server doesn't lose you money. The money was never on it. What a dead server loses you is &lt;em&gt;the ability to prove what happened&lt;/em&gt;: which order a deposit belonged to, which webhooks fired, whether that $499 payment from Tuesday ever confirmed. And if you lose the wrong file, you also lose every two-factor secret and webhook secret your install ever issued.&lt;/p&gt;

&lt;p&gt;This post is the backup and monitoring setup I'd put on any &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt; server before it takes a real payment. Paths and commands are Coldtill's, because that's what we build, but the categories apply to any self-hosted gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things, four different failure modes
&lt;/h2&gt;

&lt;p&gt;A Coldtill install has four pieces of state worth thinking about, and they fail differently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;If you lose it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL database&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pgdata&lt;/code&gt; Docker volume&lt;/td&gt;
&lt;td&gt;Payment history, settings and the encrypted hot keys. Customer funds are still safe on chain, but the records are gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the deploy directory&lt;/td&gt;
&lt;td&gt;The server secret. Sessions, two-factor secrets and webhook secrets become unreadable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;secrets/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the deploy directory&lt;/td&gt;
&lt;td&gt;The hot-wallet identity. You'd have to re-import or replace the hot wallets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold wallet seed&lt;/td&gt;
&lt;td&gt;offline, with you&lt;/td&gt;
&lt;td&gt;Everything swept so far. No software can help you here&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The row people miss is &lt;code&gt;.env&lt;/code&gt;. It holds &lt;code&gt;COLDTILL_SECRET&lt;/code&gt;, which seals the TOTP and webhook secrets stored in the database. Restore a database dump without the matching &lt;code&gt;.env&lt;/code&gt; and you get a database full of encrypted blobs you can't open: every user re-enrols two-factor and every webhook endpoint gets recreated. That's not a disaster, but it's a bad afternoon you can avoid with one &lt;code&gt;cp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the cold wallet seed isn't really a server backup at all. Coldtill never sees those keys; it only needs the address. It's on the list because it's the only one of the four that actually holds money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where things live
&lt;/h2&gt;

&lt;p&gt;If you installed with the one-line script from the &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;Coldtill GitHub repo&lt;/a&gt;, the deploy directory is &lt;code&gt;/opt/coldtill&lt;/code&gt;. The installer makes &lt;code&gt;.env&lt;/code&gt; readable only by root and the Coldtill user, so run Compose with &lt;code&gt;sudo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/coldtill
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see PostgreSQL, Caddy and the four Coldtill processes (&lt;code&gt;api&lt;/code&gt;, &lt;code&gt;watcher&lt;/code&gt;, &lt;code&gt;sweeper&lt;/code&gt;, &lt;code&gt;payouts&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Nightly database dumps
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_dump&lt;/code&gt; through the running container is all you need:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/bin/coldtill-backup&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/coldtill
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/backups/coldtill
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; coldtill coldtill &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/backups/coldtill/coldtill-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it from root's crontab (&lt;code&gt;sudo crontab -e&lt;/code&gt;) at a quiet hour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;15 3 * * * /usr/local/bin/coldtill-backup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing worth knowing: running the install script again is also how you upgrade, and it saves a database dump to &lt;code&gt;/opt/coldtill/backups/&lt;/code&gt; before it restarts on the new image. Nice, but don't treat it as your backup strategy. It only runs when you upgrade, and it sits on the same disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the dumps off the box
&lt;/h2&gt;

&lt;p&gt;A backup on the same disk as the database doesn't survive the thing you're most likely to lose: the whole server. Ship them somewhere else. The Coldtill docs use restic to an S3-compatible bucket, which I like because it's encrypted, deduplicated and has sane retention:&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;restic
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_REPOSITORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;s3:https://s3.example.com/coldtill-backups
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_PASSWORD_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/root/.restic-pass
restic init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add two lines to the end of the backup script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic backup /var/backups/coldtill
restic forget &lt;span class="nt"&gt;--keep-daily&lt;/span&gt; 14 &lt;span class="nt"&gt;--keep-monthly&lt;/span&gt; 6 &lt;span class="nt"&gt;--prune&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your provider's snapshots are a good extra layer on top of this, not a replacement. Snapshots are easy to forget about, and they live with the same company as the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;secrets/&lt;/code&gt; go somewhere offline
&lt;/h2&gt;

&lt;p&gt;These two don't change often, so they don't belong in a nightly job. Copy them once to encrypted offline storage, and again whenever they change (new hot wallet identity, rotated secret):&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 tar &lt;/span&gt;czf - &lt;span class="nt"&gt;-C&lt;/span&gt; /opt/coldtill .env secrets &lt;span class="se"&gt;\&lt;/span&gt;
  | gpg &lt;span class="nt"&gt;--symmetric&lt;/span&gt; &lt;span class="nt"&gt;--cipher-algo&lt;/span&gt; AES256 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; coldtill-keys-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz.gpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that file wherever you keep things like recovery codes. Not in the same bucket as the dumps: the whole point is that someone who gets the dumps shouldn't also get the key that unseals them.&lt;/p&gt;

&lt;p&gt;A note on &lt;code&gt;secrets/&lt;/code&gt;: the hot-wallet identity in there is encrypted under your passphrase, but by default the passphrase sits next to it in &lt;code&gt;secrets/hot_passphrase&lt;/code&gt;, so the sweeper and payouts processes can restart without you. If that combination in a backup makes you uneasy, you can run without a stored passphrase (&lt;code&gt;coldtill init --store-passphrase=false&lt;/code&gt;) and type it at start-up. The trade-off is that if &lt;code&gt;sweeper&lt;/code&gt; or &lt;code&gt;payouts&lt;/code&gt; restarts, sweeps and payouts wait for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Actually restore it
&lt;/h2&gt;

&lt;p&gt;A backup you've never restored is a hope, not a backup. Rent a spare VPS for an hour, install Docker, put back &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;secrets/&lt;/code&gt; &lt;strong&gt;from the same point in time as the dump&lt;/strong&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/coldtill
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; postgres
&lt;span class="nb"&gt;gunzip&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; coldtill-2026-11-03.sql.gz | &lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres psql &lt;span class="nt"&gt;-U&lt;/span&gt; coldtill coldtill
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sign in, check two-factor still works, open the Payments page and compare a few rows against the live server. If the two-factor prompt fails, your &lt;code&gt;.env&lt;/code&gt; and dump don't match, and you've just learned that cheaply.&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%2F0v087y07vyye1nm75xds.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%2F0v087y07vyye1nm75xds.png" alt="After a test restore, the Payments page is the quickest sanity check: same invoices, same states, same filled amounts as production." width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After a test restore, the Payments page is the quickest sanity check: same invoices, same states, same filled amounts as production.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring: what to actually alert on
&lt;/h2&gt;

&lt;p&gt;Uptime is the easy part. Coldtill exposes a health endpoint:&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;-fsS&lt;/span&gt; https://pay.example.com/healthz   &lt;span class="c"&gt;# prints: ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point UptimeRobot, Better Stack, Healthchecks.io or whatever you already use at it. The &lt;code&gt;api&lt;/code&gt; container has a Docker health check too.&lt;/p&gt;

&lt;p&gt;The more useful signals are in the logs. They're JSON, one line per event, so plain &lt;code&gt;grep&lt;/code&gt; goes a long way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/coldtill
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt; 1h api watcher sweeper payouts
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose logs sweeper | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'"level":"WARN"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what I'd page on, and why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;"level":"ERROR"&lt;/code&gt; from any process.&lt;/strong&gt; The obvious one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hot wallet … needs at least&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;sweep not possible&lt;/code&gt;&lt;/strong&gt;. The hot wallet pays gas for sweeps. When it runs dry, sweeps stop. Nothing is lost (deposits sit safely on addresses that can only send to your cold wallet), but money piles up where it shouldn't, and payouts stop too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;filled a nonce gap&lt;/code&gt;&lt;/strong&gt;. Coldtill fixes this itself with a zero-value self-transfer, but if you see it often, something is failing upstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;conversion delivery mismatch&lt;/code&gt;&lt;/strong&gt;. Only relevant if you've turned on auto-convert. A swap provider reported something Coldtill's own nodes couldn't match, so a human should look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payouts in &lt;code&gt;needs-review&lt;/code&gt;&lt;/strong&gt;. Coldtill also sends a &lt;code&gt;payout.needs-review&lt;/code&gt; webhook, so if you already have a webhook receiver, route that event to wherever your team actually reads things.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rough watcher, if you don't have a log pipeline yet:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# run every 5 minutes from cron&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/coldtill
&lt;span class="nv"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose logs &lt;span class="nt"&gt;--since&lt;/span&gt; 5m api watcher sweeper payouts 2&amp;gt;&amp;amp;1 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'"level":"ERROR"|needs at least|sweep not possible|nonce gap|delivery mismatch'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"coldtill alert on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ops@example.com
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's crude, it'll repeat itself, and it's still much better than finding out from a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet failure: a chain watcher falling behind
&lt;/h2&gt;

&lt;p&gt;The alert that's easy to forget is the one where nothing errors. If an RPC node is rate limiting you, or quietly serving stale data, the watcher just... sees blocks late. No crash, no error line, payments sitting in "pending" longer than they should.&lt;/p&gt;

&lt;p&gt;The dashboard shows a "last block processed" per network under &lt;strong&gt;Nodes &amp;amp; networks&lt;/strong&gt;. If one of those stops moving while the chain keeps going, your watcher is behind. The fix is usually the same: public RPC endpoints are fine for testing and rate limited for everything else, so for mainnet use your own nodes or a paid provider, and add at least two per network so failover has somewhere to go.&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%2F6j4eak482kzhk9p8czyu.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%2F6j4eak482kzhk9p8czyu.png" alt="The overview shows confirmed volume and recent payments at a glance. A long run of &amp;quot;pending&amp;quot; here is often the first visible sign of a node problem." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The overview shows confirmed volume and recent payments at a glance. A long run of "pending" here is often the first visible sign of a node problem.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;p&gt;Before mainnet, I want to be able to say yes to all of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nightly dumps run, and land off the server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;secrets/&lt;/code&gt; are in encrypted offline storage, separate from the dumps.&lt;/li&gt;
&lt;li&gt;I've restored onto a spare server at least once, and two-factor worked afterwards.&lt;/li&gt;
&lt;li&gt;An uptime monitor is watching &lt;code&gt;/healthz&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Something tells me about &lt;code&gt;ERROR&lt;/code&gt; lines, hot wallet warnings and &lt;code&gt;needs-review&lt;/code&gt; payouts.&lt;/li&gt;
&lt;li&gt;Someone looks at hot wallet balances, and "last block processed", more than once a month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. It's the same discipline you'd apply to any Postgres-backed service. The difference is that with a payment server, the records &lt;em&gt;are&lt;/em&gt; the product. The coins will be fine on chain either way; the question is whether you can still tell which customer paid for what.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>selfhosted</category>
      <category>docker</category>
      <category>monitoring</category>
    </item>
    <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>
