The TypeScript MPP SDK (mpp-test-sdk on npm) lets you build and test pay-per-request APIs on Solana in minutes. One function on the server. One function on the client. No Stripe, no database, no wallet pop-ups.
The problem with API monetisation today
Every paid API you've ever built follows the same playbook: create a Stripe account, issue API keys, build a usage-tracking database, write billing logic, handle failed payments, deal with dunning emails. You spend more time on billing infrastructure than on the API itself.
HTTP 402 was supposed to fix this. It's been reserved in the HTTP spec since 1999, sitting there with the description "Payment Required", waiting for someone to actually use it. The missing piece was always a payment layer — something fast enough to pay per request without a 3-second checkout flow.
Solana makes it possible. Sub-second finality, near-zero fees, and a simple transfer instruction. MPP wires it all together.
What MPP gives you
On the server: a single middleware that wraps any handler. When a request arrives without proof of payment, it returns a 402 with a Payment-Request header describing what to pay and where. When proof arrives, it verifies the on-chain transaction and serves the response.
On the client: a fetch replacement that detects 402, pays automatically on Solana, and retries with a Payment-Receipt header. On devnet it airdrops SOL automatically — no setup required.
Installation
npm install mpp-test-sdk
Server (Express)
import express from "express";
import { createTestServer } from "mpp-test-sdk";
const app = express();
const server = await createTestServer({ network: "devnet" });
console.log("Recipient:", server.recipientAddress);
app.get(
"/api/data",
server.charge({ amount: "0.001" })(async (_req, res) => {
res.json({ result: "here is your data" });
})
);
app.listen(3001);
That's the whole server. No billing database. No webhook handler. The middleware issues the 402 challenge and verifies the on-chain Solana transaction automatically.
Client
import { createTestClient } from "mpp-test-sdk";
const client = await createTestClient({
network: "devnet",
onStep: (step) => console.log(`[${step.type}] ${step.message}`),
});
console.log("Wallet:", client.address);
const res = await client.fetch("http://localhost:3001/api/data");
const data = await res.json();
console.log(data); // { result: "here is your data" }
createTestClient generates a Solana keypair, airdrops 2 SOL on devnet, and returns a client whose fetch method handles the entire 402 flow automatically.
What happens under the hood
Client Server
| |
|── GET /api/data ─────────────>|
| |── no Payment-Receipt header
|<── 402 Payment-Request ───────| Payment-Request: solana; amount="0.001"; recipient="9xK..."
| |
|── [build Solana tx] ──────────|── (client signs and submits on-chain)
|── [confirm on chain] ─────────|
| |
|── GET /api/data ─────────────>| Payment-Receipt: solana; signature="3xK..."; amount="0.001"
| |── [verify tx on-chain]
|<── 200 OK ────────────────────|
No off-chain coordination. No shared secret. The server verifies the actual Solana transaction — recipient address, amount, confirmation status — directly via the RPC.
Lifecycle callbacks
const client = await createTestClient({
network: "devnet",
onStep: (step) => {
switch (step.type) {
case "wallet-created": console.log("Wallet:", step.data.address); break;
case "funded": console.log("Funded via airdrop"); break;
case "payment": console.log("Paying", step.data.amount, "SOL"); break;
case "success": console.log("Done:", step.data.status); break;
}
},
});
Every stage of the flow emits a typed event: wallet-created, funded, request, payment, retry, success, error.
Mainnet
import { createTestClient } from "mpp-test-sdk";
const client = await createTestClient({
network: "mainnet",
secretKey: Uint8Array.from(myKeypairBytes), // 32-byte seed or 64-byte keypair
});
On mainnet, no airdrop is available. Pass a pre-funded keypair. The rest of the flow is identical.
Drop-in fetch (shared wallet)
import { mppFetch } from "mpp-test-sdk";
// Uses a lazily-created shared devnet wallet
const res = await mppFetch("http://localhost:3001/api/data");
mppFetch creates a single client on the first call and reuses it. Call mppFetch.reset() to discard the wallet and force a fresh one.
Error handling
import { MppFaucetError, MppPaymentError, MppTimeoutError } from "mpp-test-sdk";
try {
const res = await client.fetch("http://localhost:3001/api/data");
} catch (err) {
if (err instanceof MppFaucetError) console.error("Airdrop failed:", err.address);
if (err instanceof MppPaymentError) console.error("Payment failed:", err.status);
if (err instanceof MppTimeoutError) console.error("Timed out after:", err.timeoutMs, "ms");
}
Integration testing
The TypeScript SDK ships with a full integration test harness — spin up a server in-process, run a client against it, assert on the receipts.
import { createTestClient, createTestServer } from "mpp-test-sdk";
import express from "express";
test("charges 0.001 SOL per request", async () => {
const server = await createTestServer({ network: "devnet" });
const app = express();
app.get("/data", server.charge({ amount: "0.001" })((_, res) => res.json({ ok: true })));
const httpServer = app.listen(0);
const { port } = httpServer.address() as { port: number };
const client = await createTestClient({ network: "devnet" });
const res = await client.fetch(`http://localhost:${port}/data`);
expect(res.status).toBe(200);
httpServer.close();
});
What's next
- mpptestkit.com — interactive playground with live devnet transactions
- npm: mpp-test-sdk — package
- GitHub — source, issues, SDK roadmap
Top comments (0)