Skip to content
← All posts
building payments

PassimPay cut my crypto minimum from $14.51 to $2

Four in ten Royal Subz orders were too small to pay with USDT on TRC20. How I moved crypto checkout to PassimPay in three days, why the docs were wrong about the webhook, and how the order of two fees became the price.


Royal Subz’s instant crypto checkout had a minimum order of $14.51.

That was NOWPayments’ live minimum for USDT on TRC20. On BEP20 it was $8.86. Our median order is $15.99. When I counted, 51 of the 123 payments in the previous 120 days, 41%, were below $14.51. Four in ten orders could not use TRC20 at all.

NOWPayments had a second, quieter problem. It credited a payment only when its webhook arrived. There was no background check, so a lost webhook meant a paid customer who was never credited, and nobody knew.

PassimPay’s minimums for the same coin were $2 on TRC20, and $0.50 on BEP20, Polygon and Solana. I started reading its documentation on 7 October and switched it on for customers at 13:50 UTC on 9 October. NOWPayments is still in the code, switched off, as a backup.

The second gateway inherits the first one’s answers

Three days is only possible because of the EPS integration a week earlier. That project had already answered the hard questions, and PassimPay was wired onto the same framework: an attempts table with every amount frozen at creation, a single verifyAttempt() function that is the only code allowed to credit a payment, the gateway’s callback treated as a trigger and never as proof, a reconciler on a five-minute cron, an event log, an admin banner for payments that need a human, and test invoices that can be locked to a payment method customers can’t see yet.

The rule stayed the same too. A return URL can be typed by anyone, and a webhook, even a signed one from a listed address, only ever starts a check. The only thing that credits a crypto payment is my server calling PassimPay’s /v3/orderstatus with my own key.

What was new was everything specific to crypto: coins and networks, network fees that move by the hour, payments that arrive short, and an invoice that never really closes.

The plugins knew what the docs didn’t

I read all 34 pages of PassimPay’s API documentation. Requests are signed with an HMAC-SHA256 over the platform ID, the exact request body and the API key. PassimPay’s PHP and JavaScript samples disagree on whether a / in the body is escaped before signing, so I copied the reference Node client byte for byte and made sure no value I send ever contains a /.

Three things in the docs changed the plan:

  • There is no sandbox. Every test would be real money.
  • Webhooks fire only on a successful deposit, and are retried only twice. A reconciler wasn’t a safety net; it was required.
  • Crypto invoices can’t send the customer back. The docs reserved the return URL for a fiat on-ramp.

The third one I didn’t accept. PassimPay’s own OpenCart module has a “Return URL” field beside its callback URL, and its PrestaShop module has a return controller that reads an order ID from the query string. The feature existed; it just wasn’t in the API docs. Support confirmed it: set a Successful URL in the platform settings and PassimPay sends the customer there with ?order_id= after paying, in the same tab.

Support’s other answers reshaped the design. API invoices stay payable for 72 hours, can’t be shortened and can’t be cancelled. I had planned a 60-minute payment window. Instead, every attempt stays open for 72 hours, and pressing Pay again on the same network within an hour reuses the open PassimPay page rather than leaving a second payable invoice behind the same order.

A dashboard setting called “Invoice Amount Rounding” turned out to be PassimPay’s own under-payment tolerance: set it to $5 and a $100 invoice paid with $95 counts as paid. Ours had to agree with it exactly, or the two systems would disagree about what “paid” means.

A probe that was allowed to credit nothing

With no sandbox, Phase 0 shipped the client, a diagnostic page and a webhook in log-only mode. It verified and recorded everything PassimPay sent and credited nothing. Then I paid a real invoice to see what actually happens.

The first probe was $1, until I found Binance won’t withdraw less than $3. The second was $3, paid with 2.99 USDT on BEP20, inside PassimPay’s 5-cent rounding.

The webhook came from a listed address and its signature was computed over the raw body, as documented. It had no type field at all. The documentation promises "type": "deposit", and my log-only handler only looked up a payment’s status when that field said so. Phase 2 would have shipped a webhook that ignored every real payment.

I fixed it to recognise a deposit by what a deposit actually carries: an order ID, a transaction hash and a payment ID. To verify the fix, I didn’t need a second payment. PassimPay’s dashboard has a Resend button on each transaction, and the resent webhook was accepted and followed immediately by a status check.

Probe before you build. One real payment through a handler that can’t spend money found the bug before any code depended on it.

The probe also settled a question the docs couldn’t: in what order does PassimPay take its fees?

The order of two fees is the price

Every PassimPay payment loses two fees: the network’s cost of moving the money, and a 1.2% service fee. Royal Subz customers pay both, shown before they leave the site, and the business should receive the full price.

The probe showed PassimPay takes the network fee first, then 1.2% of what’s left. So the gross-up adds the network fee after dividing out the percentage:

charged = ceil_to_cent( price ÷ (1 − 1.2%) + network fee )

Assume the opposite order and every customer is overcharged by 1.2% of the network fee. On TRC20 that’s a cent or two, small enough to miss and charged on every single order. The calculator is the same BigInt module that prices EPS, extended with a fixed part. A test sweeps 63 price and fee pairs and proves the business always receives the full price, overshooting by less than a cent.

First real order · USDT on BEP20

  1. Order price

    what the product costs

    $3.99

  2. Fees added at checkout

    1.2% grossed up, plus the network fee

    + $0.08

  3. Charged

    frozen on the attempt

    $4.07

  4. Customer sent

    a cent short, inside the $0.05 tolerance

    $4.06

  5. Network fee

    taken first: the cost of sweeping the one-time address

    − $0.0299

  6. PassimPay 1.2%

    taken second, from what is left

    − $0.0483

  7. Credited to Royal Subz

    PassimPay’s own figure

    $3.9818

Our payment row: 3.99 + 0.07 − 0.0782 = 3.9818

Price plus the fee actually collected, minus PassimPay’s total cut, matches what PassimPay credited to the hundredth of a cent.

PassimPay takes the network fee first and 1.2% of the rest second. A gross-up that assumed the opposite order would overcharge every order by 1.2% of the network fee.

The probe had one more surprise. The network fee charged was exactly twice the figure in PassimPay’s public coin catalogue. Support had told me the two were the same. I asked again, this time with the numbers from the real payment, and got the full answer: invoices carry the real cost of sweeping the customer’s one-time address to the main wallet. On BEP20, that really is twice the catalogue figure. TRC20 is a flat 4 TRX. Bitcoin costs nothing.

That answer arrived in the middle of testing, and it mattered. With the 2× default, a TRC20 customer would have paid $3.34 in network fee instead of about $1.34, and Bitcoin could never have been offered. The same hour, a network fee could become a fixed amount of another coin, valued at that coin’s live rate, or exactly zero. A $5 order on TRC20 dropped from $8.41 to about $6.41.

Fees move between the moment a customer sees one and the moment they press Continue, so the server re-quotes from the same one-minute cache. If the total has gone up, it refuses and shows the new total instead of quietly charging it.

Short, late and double payments

Crypto payments don’t arrive as neatly as card payments. Customers often send from an exchange, which can take its withdrawal fee out of the amount typed, so a payment a cent short is ordinary.

The tolerance is $0.05, matching PassimPay’s rounding. Paid in full or more: credited, with any overpayment recorded. Within five cents: credited, with the shortfall recorded. Short by more: the payment stays open, because the customer can top up the difference on PassimPay’s page for as long as the invoice lives. That only works with PassimPay’s “Invoice for only one payment” setting switched off. If the invoice expires with part of the money in, it goes to the admin banner for a decision.

Because invoices live for 72 hours, the reconciler is sized for them: nothing for two minutes, then every five minutes until ninety minutes, when most payments land, then hourly, then every six hours, and a final check at 73 hours. A payment for an invoice that’s already paid becomes paid_duplicate; one that lands after our invoice expired becomes paid_after_close. Neither is credited automatically.

The cron that said it ran

I reported the reconciler’s cron job set up and its test run successful. Reading production afterwards told a different story: no heartbeat, and the open test payment had only been checked by my own two presses of Re-verify. The EPS job’s heartbeat was five minutes old, so the service was fine. The new job was sending GET to an endpoint that only accepts POST, and cron-job.org’s “test run done” meant only that a request had gone out.

One change in the job’s settings fixed it, and the next run wrote its heartbeat and checked the open payment itself. Now I confirm a cron by its heartbeat in the log, never by the scheduler’s own word.

The first real order

On a $3.99 test invoice locked to the still-disabled Crypto method, I chose USDT on BEP20. The dialog quoted $4.07. I sent 4.06, a cent short, and it was credited by the tolerance.

  1. 07:53:47

    Payment started

    Coin and network chosen; amount frozen at $4.07

  2. 07:55:47

    Webhook arrives

    Signed, from a listed address. Only a reason to check

  3. 07:55:48

    Credited

    Our server asked PassimPay’s status API: paid

  4. 07:56:23

    Customer returns

    Already paid by the time the page loads

Two minutes from start to credit, one second after the webhook, thirty-five seconds before the customer came back. Times are UTC, from the PassimPay Log.

The number I cared about most was at the bottom of the payment row. Price plus the fee collected, minus PassimPay’s cut, came to $3.9818, exactly what PassimPay said it had credited. Profit in my reports now matches the gateway’s own ledger to the hundredth of a cent.

Words and refunds

The money was right everywhere before the labels were. Reports would have called PassimPay’s cut “EPS commission” and shown a payment as passimpay. They now say “Crypto · USDT BEP20” and “PassimPay fees”, and the CSV gained a Paid With column at the end so no existing column moved.

Refunds go out by hand from my own wallet, like EPS refunds. The refund dialog shows the coin, network, amount received and transaction hash, plus a warning never to refund to the address the payment came from. That’s usually an exchange’s shared wallet, and money sent there may never reach the customer.

Going live

One last fix came from looking, not testing. The network list made the whole dialog scroll, with the browser’s default scrollbar running down its rounded edge. Now only the lists scroll, and the title, fee note and Continue button stay put.

Then: merge, deploy in 57 seconds, check that the return page was live, NOWPayments off at 13:35, Crypto on at 13:50. Two minutes later, the first customer opened a PassimPay page, for $6.10 on BEP20. That’s below NOWPayments’ minimum on both TRC20 and BEP20.

What the second gateway taught me

The framework from EPS held up. Most of what was hard this time came from places where the documentation and reality disagreed.

The webhook didn’t carry the field the docs promised. The return URL the docs said didn’t exist was in the vendor’s own plugins. Support’s “the same for both” was off by a factor of two, and the right answer only came once I asked with numbers from a real payment. A cron job’s “success” was a request that never did anything.

None of it was visible from reading. All of it showed up the first time real money moved through a system built to record everything and trust nothing.