Why we chose crypto-first billing (and what broke first)
Published November 2025 · by OctopusLab Engineering
When we started designing OctopusLab's billing, we did the obvious thing: we opened the Stripe docs, scaffolded a POST /api/billing/checkout, and almost shipped a Checkout Session. That was the plan for about a week. Then we looked at where our beta waitlist was actually coming from — Lagos, Lima, Jakarta, Karachi, São Paulo, Tashkent, Hanoi — and realized that if we shipped that route as the primary path, we'd be quietly excluding most of the people we built this thing for.
So we flipped it. Crypto-first, cards second-class. This post is about why, what the architecture looks like, and the specific thing that broke at 2 a.m. the night we tried to test it end-to-end.
The decision: card rails are a geographic filter
We're a global AI SaaS. Our entire pitch is that a developer in any country with a laptop and an internet connection should be able to spin up a production-grade website with the same tools a YC-backed founder in SF has. Then we'd gate access behind Stripe?
The numbers are well-documented, but worth restating:
- Stripe officially supports payments from cards issued in ~46 countries. That sounds like a lot until you realize it doesn't include Nigeria (top 3 in our waitlist), Pakistan, Bangladesh, Vietnam, most of Central Asia, large parts of Latin America, and effectively all of Africa outside South Africa, Kenya, and a couple of others.
- PayPal is worse on the receive side and worse on the developer side. It also has a habit of freezing accounts of small SaaS founders without warning.
- Card penetration in our top-10 waitlist countries averages under 40%. Even when a card exists, cross-border online payments routinely fail at the issuer level. Anyone who's tried to buy a $20 SaaS sub on a Vietnamese debit card knows the dance.
The framing we landed on: Stripe-first billing is discrimination by infrastructure. Not intentional, but the effect is the same. If we pick rails that exclude 40-60% of our target market by default, we've made a product decision, not a billing decision.
Crypto rails — specifically stablecoin rails — don't have this problem. USDT and USDC settle the same way in Lagos and London. The friction (custody, on-ramping, gas) absolutely exists, but it's friction we can put on ourselves with good UX, not a hard card_declined from a bank that's never heard of us.
So we picked our payment processor based on coverage, not familiarity:
- NOWPayments for general crypto invoicing — they support ~300 currencies, give us a clean webhook contract, and handle the volatility hedging.
- xRocket for our Telegram audience (which, if you've been paying attention to where AI-curious developers in CIS / SEA hang out, is a serious channel).
- Stripe still exists, but it's a checkbox in our settings page under "I'd prefer to pay with a card." Fewer than ten clicks deep on the pricing page. Intentional.
The architecture: address-per-invoice, polled by webhook
The flow looks like this:
- User clicks Upgrade to Pro on the dashboard.
- Our NestJS 11 API on Railway calls
POST https://api.nowpayments.io/v1/invoicewith the plan price, currency preferences, and a callback URL. - NOWPayments returns a unique deposit address scoped to that invoice.
- We persist the invoice in Neon Postgres with
(id, org_id, address, amount, currency, status='pending', expires_at). - We render the deposit address + QR in our hosted invoice page.
- When the customer sends funds, NOWPayments confirms on-chain, then hits our webhook.
- We verify the HMAC, look up the invoice by
payment_id, mark itpaid, upgrade the org's plan, and invalidate downstream caches.
The address-to-org mapping lives in our invoices table, indexed on payment_id (NOWPayments's ID, our foreign key). We do not derive anything from the address itself — that would be brittle the moment NOWPayments rotates addresses or reuses them across tenants.
Here's the Drizzle schema, trimmed to the relevant columns:
export const invoices = pgTable('invoices', {
id: uuid('id').primaryKey().defaultRandom(),
orgId: uuid('org_id').notNull().references(() => orgs.id),
provider: text('provider').notNull(), // 'nowpayments' | 'xrocket' | 'stripe'
paymentId: text('payment_id').notNull().unique(),
address: text('address'),
amount: numeric('amount', { precision: 18, scale: 8 }).notNull(),
currency: text('currency').notNull(),
status: text('status').notNull().default('pending'),
plan: text('plan').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at').notNull(),
paidAt: timestamp('paid_at'),
});
The provider column matters: we want a single billing surface that can handle three rails without a sea of if/else in the application code. NOWPayments and xRocket both send IPN-style webhooks; Stripe sends its own. Each provider has an adapter under src/billing/providers/ that normalizes the payload into a PaidInvoice event, and the rest of the code doesn't care which rail it came in on.
Webhook security: HMAC against the raw body
This is where it gets interesting.
NOWPayments signs every webhook with HMAC-SHA512 of the JSON body, using a secret you configure in their dashboard. The signature comes in as x-nowpayments-sig. The catch — and this is the entire reason this post exists — is that you must verify against the exact bytes they signed. Not the parsed JSON. Not a re-stringified version. The raw request body, byte-for-byte.
Express (and therefore Nest's default Express adapter) eats the body for breakfast as part of the JSON middleware. By the time your controller sees it, the original bytes are gone and you're holding a Record<string, unknown> that almost certainly serializes differently from what NOWPayments hashed (key order, whitespace, Unicode escapes — pick your poison).
The fix is two-part. First, enable raw body capture at the app level:
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
rawBody: true,
});
Second, validate in the controller using the stashed buffer:
@Post('webhooks/nowpayments')
async handle(@Req() req: RawBodyRequest<Request>, @Headers('x-nowpayments-sig') sig: string) {
const expected = createHmac('sha512', process.env.NOWPAYMENTS_IPN_SECRET!)
.update(req.rawBody!) // <-- the bytes Express stashed, not JSON.stringify(req.body)
.digest('hex');
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
throw new UnauthorizedException();
}
return this.billing.markPaid(req.body as NowPaymentsIpn);
}
timingSafeEqual because comparing HMAC digests with === is the kind of mistake you only get to make once before someone publishes a blog post about you, not by you.
What broke first: ngrok and the silent byte injection
Predictably, none of this worked the first night we tried to test end-to-end against the real NOWPayments sandbox.
The setup: NestJS running locally on :4000, exposed to the public internet via ngrok so NOWPayments could reach our webhook. We sent a test payment, watched NOWPayments's dashboard show "Confirmed," and saw our endpoint return 401 Unauthorized. Every. Single. Time.
The signature mismatch was deterministic, which is the worst kind. We logged the expected and received signatures — totally different. Logged the body — looked identical to what NOWPayments showed they'd sent. Logged it byte-by-byte — and there, at the end, was a stray \n. Sometimes. Other times the difference was a content-encoding change that triggered Express to decompress and re-buffer, which subtly altered what rawBody captured.
ngrok was the culprit. The free tier injects diagnostic headers and, depending on the version, will re-encode the body when it negotiates Accept-Encoding with the origin. For a normal JSON API, this is invisible. For HMAC verification, it's catastrophic.
There were a few possible fixes, and we tried them in order:
- Use ngrok with
--host-header=rewriteand disable compression. Helped, but didn't fully eliminate the drift on bigger payloads. - Use Cloudflare Tunnel instead. Better, but still proxied.
- The actual fix: make our raw-body handler robust to it, and stop testing webhooks through tunnels at all. We added a
dev:webhookscript that uses NOWPayments's "Send test IPN" feature pointed at a Railway preview deployment, which is what the production webhook will hit anyway. End-to-end identical to prod, no tunnel in the middle.
The general lesson: anything that sits between the signer and your raw-body buffer is a footgun. This applies equally to Stripe's CLI forwarding, to API gateways that rewrite bodies, to logging middleware that touches the request stream before your verifier runs. Order of middleware matters. Capture the raw bytes first, before anything else can mutate them.
We now have a small sanity check that runs in CI: it boots the app, replays a fixture IPN with a known-good signature, and asserts a 200. It would have caught the original bug if we'd written it first instead of debugging it at 2 a.m.
Upgrading the plan: one UPDATE, one cache bust
The actual plan upgrade, post-verification, is intentionally boring:
async markPaid(ipn: NowPaymentsIpn) {
const [updated] = await this.db
.update(invoices)
.set({ status: 'paid', paidAt: new Date() })
.where(eq(invoices.paymentId, ipn.payment_id))
.returning();
if (!updated || updated.status !== 'paid') return;
await this.db
.update(orgs)
.set({ plan: updated.plan, planUpdatedAt: new Date() })
.where(eq(orgs.id, updated.orgId));
await this.realtime.broadcast(updated.orgId, { type: 'plan.upgraded', plan: updated.plan });
}
Two writes, both idempotent on payment_id. NOWPayments will sometimes deliver the same IPN twice (network retries, their own retry policy on 5xx); our UPDATE ... RETURNING only flips the row once because we filter on status = 'pending' in the real version. The broadcast hits a WebSocket channel scoped to the org, and on the Next.js 16 client, TanStack Query picks it up and invalidates the ['org', orgId] query key. The dashboard re-renders with the new plan badge within a second of confirmation.
We don't use Redis for this. We tried. For our current scale it was a layer of complexity that did nothing the Postgres write + WebSocket fanout didn't already do. We may add it back later; we haven't needed it.
The 10% crypto discount: honest accounting
We give a 10% discount on every crypto invoice. The marketing pitch is "we pass the savings to you." The honest version is: NOWPayments charges us 0.5%, Stripe charges us ~2.9% + 30 cents, and the foreign exchange spread on cross-border card transactions can add another 1-2% before the customer even sees the bill. Crypto saves us real money on every transaction, and we'd rather hand that to the customer than book it as margin while we're pre-launch.
It also aligns incentives. We want customers to use the rails that work everywhere and cost us less. A 10% discount is enough to nudge someone who has both options toward crypto, which over time means fewer card chargebacks, less fraud surface, and a customer base that's already comfortable with the rails we're building everything else around.
What's next
The current setup works, but it's a v1. Three things are queued up:
- USDC on Solana for instant settlement. NOWPayments confirms BTC and ETH in minutes, which is fine, but a Solana invoice settles in under a second and costs effectively nothing in fees. For users buying a $19 plan, sub-second confirmation is the difference between "click pay and see your plan upgrade live" and "wait, refresh, wonder if it worked."
- xRocket as a first-class option for Telegram users. A huge slice of our early audience already has a Telegram wallet with funds in it. Going from "decide to upgrade" to "paid" should be three taps inside Telegram, not a copy-pasted address into an external wallet.
- A hosted invoice page that lives inside the dashboard. Right now we hand off to a NOWPayments-hosted page. It works, but it breaks the visual continuity of the product, and we lose the chance to show real-time confirmation status with our own UI. Building this in-house means we own the entire flow from "click upgrade" to "you're on Pro," with no third-party-branded redirects in between.
We'll write up each of those as we ship them. The goal hasn't changed: a developer anywhere in the world, with any payment instrument they happen to have, should be able to pay us in under a minute. Cards are a tool for that, not the foundation.
Want updates? Watch github.com/jarwiscode/OctopusLab or write to hello@octopus-lab.app.