· #servicenow#ai#stripe#cloudflare#sndevio#project

Adding Stripe to sndev.io

The monetization journey for an MCP server — why I built a custom stripe-lite client, why licenses are JWTs, and where this fits alongside x402 payments.

The email came in on a Tuesday night. “Hey — I love sndev.io, is there a plan I can pay for? Like a normal subscription? I just want to put it on my company card.”

I stared at it for a minute. The whole point of the x402 work had been that agents could pay per request — no accounts, no dashboards, no invoices. It was the right model for the thing I thought I was building.

But this person wasn’t an agent. This person had a company card and a procurement process and a need for a monthly receipt.

Okay. Fine. Stripe it is.

Why not just bolt it onto the MCP worker

The MCP itself runs as a Cloudflare Worker. It’s small, it’s fast, it does one thing — answer search queries against the ServiceNow docs with low latency. I had a strong gut feeling about not touching that worker to add billing.

Partly that’s blast radius. If I ship a bug in the checkout flow, I do not want the side effect to be that every agent hitting /mcp gets a 500. Two completely different reliability budgets. The MCP needs to be boring and up. The billing code is going to change a lot while I figure out what I’m selling.

Partly it’s deploy cadence. I wanted to push billing fixes at 1am without thinking “wait, does this also redeploy the thing paying customers are currently using?”

So: a second worker. license-worker. Its own folder, its own wrangler.jsonc, its own D1 database, deployed independently. The MCP worker doesn’t import a single line from it. They only talk via the license token the user presents in a header, which I’ll get to.

The stripe-node SDK problem

First thing I tried was the obvious thing. bun add stripe, import it, call stripe.subscriptions.retrieve(id), ship it.

The Worker bundle went from something I don’t worry about to something I do worry about. The stripe package is ~400KB gzipped and pulls in a bunch of node compat shims that Cloudflare Workers don’t love. For a worker whose entire job (at least in the reconciliation cron) is one GET call to one Stripe endpoint, that felt absurd.

So I wrote stripe-lite.ts. It’s a hand-rolled fetch wrapper around the couple of Stripe REST endpoints I actually use — basically subscriptions.retrieve and a thin shim for the webhook parsing. It’s maybe eighty lines. It pins an API version in the header. It handles the one Stripe quirk I knew about going in (they’ve been moving current_period_end from the top-level subscription object into items.data[0], and I wanted to be defensive about which one I read).

If I ever need half the Stripe API, I’ll graduate this to the real SDK without shame. But until then, eighty lines beats four hundred kilobytes.

Licenses as JWTs

The other early call was: what does a paying customer actually hold?

The boring answer is a license key — a random string stored in the DB, and the MCP worker checks it on every request. That works. It’s also a database round-trip on the hot path, for a worker whose whole personality is “respond in under 50ms.” And now my MCP worker needs to know how to talk to my license DB, which was exactly the coupling I’d just spent a paragraph avoiding.

So licenses are signed JWTs instead. The license-worker signs a token with the user’s plan and expiry baked in, using a key only it holds. The MCP worker holds the public key and just verifies the signature. No DB lookup. No cross-worker call. The two workers share a key pair and nothing else.

The tradeoff is well-known: you can’t instantly revoke a JWT. If someone cancels, they technically keep working until their token expires. I made the expiry short enough that I don’t care, and added a revocation list path I haven’t had to use yet. (I’ll care about this more when I have more than a handful of paying users. Today is not that day.)

An admin panel, because I got tired of SQL

Somewhere around the fourth time I opened the D1 console to check who had an active subscription, I gave up and built admin.ts — a tiny HTML admin panel that lives inside the worker itself. Same worker, different route, behind a basic auth header.

It is not pretty. It shows a table of customers, their plan, when their current period ends, and a button to issue a refund. That’s it. But it means I can answer “did that person’s payment actually go through” from my phone in ten seconds instead of wiring up a console and writing a query. The minimum useful version of a thing is almost always smaller than you think.

Two payment rails, one product

So now sndev.io has two ways to pay. An agent can hit the MCP with an x402 wallet and get charged per request, no account required. A human can go to the checkout page, enter a card, get a JWT license, and paste it into their MCP client config.

Same service. Completely different rails. The x402 path is for the autonomous future I believe is coming. The Stripe path is for the Tuesday-night email from someone with a company card who just wants a receipt.

Both of those people are real. I’m glad I built for both.

-D