Add x402 micropayments: pay-per-request screenshots via USDC on Base

SnapAPI now supports the x402 protocol for pay-per-request access.
AI agents can pay $0.01/screenshot or $0.005/conversion with USDC
on Base network - no signup or API key needed.

- x402.js: Payment verification module using @x402/core + PayAI facilitator
- server.js: Dual auth (x402 Payment-Signature OR X-API-Key)
- Returns HTTP 402 with payment requirements for unauthenticated requests
- API docs updated to v3.0.0 with x402 pricing info
- CORS headers expose Payment-Required/Payment-Response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
OpSpawn 2026-02-06 09:46:19 +00:00
parent 9361f9e4e2
commit da6dc035ed
4 changed files with 4646 additions and 70 deletions

4344
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,9 @@
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"@x402/core": "^2.3.0",
"@x402/evm": "^2.3.0",
"@x402/express": "^2.3.0",
"marked": "^17.0.1", "marked": "^17.0.1",
"puppeteer-core": "^24.0.0" "puppeteer-core": "^24.0.0"
}, },

217
server.js
View File

@ -6,6 +6,7 @@ const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { marked } = require('marked'); const { marked } = require('marked');
const payments = require('./payments'); const payments = require('./payments');
const x402 = require('./x402');
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/google-chrome'; const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/google-chrome';
@ -65,6 +66,52 @@ function sendError(res, status, message) {
sendJson(res, status, { error: message }); sendJson(res, status, { error: message });
} }
// Authenticate via x402 micropayment or API key
// Returns { ok: true, keyData, settleFn } or { ok: false } (response already sent)
async function authenticate(req, res, parsed) {
if (x402.hasX402Payment(req)) {
const payResult = await x402.processPayment(req);
if (!payResult.allowed) {
const headers = { 'Access-Control-Allow-Origin': '*', ...payResult.headers };
res.writeHead(payResult.status, headers);
res.end(payResult.isHtml ? payResult.body : JSON.stringify(payResult.body));
return { ok: false };
}
return { ok: true, keyData: null, settleFn: payResult.settle };
}
const apiKey = req.headers['x-api-key'] || parsed.query.api_key;
if (!apiKey) {
// No auth - return x402 payment requirements
const payResult = await x402.processPayment(req);
if (!payResult.allowed && payResult.status === 402) {
const headers = { 'Access-Control-Allow-Origin': '*', ...payResult.headers };
res.writeHead(402, headers);
res.end(payResult.isHtml ? payResult.body : JSON.stringify(payResult.body));
return { ok: false };
}
sendError(res, 401, 'Auth required: X-API-Key header, ?api_key= param, or x402 Payment-Signature header.');
return { ok: false };
}
if (!apiKeys[apiKey]) {
sendError(res, 401, 'Invalid API key.');
return { ok: false };
}
const keyData = apiKeys[apiKey];
resetMonthlyUsage(keyData);
if (keyData.used >= keyData.limit) {
sendError(res, 429, `Monthly limit reached (${keyData.limit}). Upgrade your plan.`);
return { ok: false };
}
if (!checkRateLimit(apiKey)) {
sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.');
return { ok: false };
}
return { ok: true, keyData, settleFn: null };
}
// Analytics // Analytics
let stats = { total_captures: 0, screenshots: 0, pdfs: 0, md_conversions: 0, errors: 0 }; let stats = { total_captures: 0, screenshots: 0, pdfs: 0, md_conversions: 0, errors: 0 };
const ANALYTICS_FILE = path.join(__dirname, 'analytics.json'); const ANALYTICS_FILE = path.join(__dirname, 'analytics.json');
@ -279,7 +326,8 @@ const server = http.createServer(async (req, res) => {
res.writeHead(204, { res.writeHead(204, {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-API-Key', 'Access-Control-Allow-Headers': 'Content-Type, X-API-Key, Payment-Signature, X-Payment',
'Access-Control-Expose-Headers': 'Payment-Required, Payment-Response',
}); });
res.end(); res.end();
return; return;
@ -305,13 +353,27 @@ const server = http.createServer(async (req, res) => {
if (parsed.pathname === '/api') { if (parsed.pathname === '/api') {
sendJson(res, 200, { sendJson(res, 200, {
service: 'SnapAPI - Document Generation Suite', service: 'SnapAPI - Document Generation Suite',
version: '2.0.0', version: '3.0.0',
description: 'Screenshots, PDFs, and Markdown conversion API', description: 'Screenshots, PDFs, and Markdown conversion API with x402 micropayments',
built_by: 'AI Agent (Claude) - transparent about AI authorship', built_by: 'OpSpawn (AI Agent) - transparent about AI authorship',
payment: {
x402: {
description: 'Pay-per-request via x402 protocol (no signup needed)',
network: 'Base (USDC)',
prices: x402.PRICES,
protocol: 'https://x402.org',
how: 'Send Payment-Signature header with signed USDC authorization',
},
subscription: {
description: 'Monthly subscription via USDC on Polygon',
endpoint: 'POST /api/subscribe',
},
},
endpoints: { endpoints: {
'GET /api/capture': { 'GET /api/capture': {
description: 'Capture a screenshot or PDF from a URL', description: 'Capture a screenshot or PDF from a URL',
auth: 'X-API-Key header or ?api_key= query param', auth: 'X-API-Key header, ?api_key= query param, OR x402 Payment-Signature header',
x402_price: x402.PRICES.capture,
params: { params: {
url: 'Target URL (required)', url: 'Target URL (required)',
format: 'png (default), jpeg, or pdf', format: 'png (default), jpeg, or pdf',
@ -326,12 +388,14 @@ const server = http.createServer(async (req, res) => {
}, },
'POST /api/md2pdf': { 'POST /api/md2pdf': {
description: 'Convert Markdown to PDF', description: 'Convert Markdown to PDF',
auth: 'X-API-Key header', auth: 'X-API-Key header OR x402 Payment-Signature header',
x402_price: x402.PRICES.md2pdf,
body: 'JSON: { markdown, theme?, paperSize?, landscape?, fontSize?, margins? }', body: 'JSON: { markdown, theme?, paperSize?, landscape?, fontSize?, margins? }',
}, },
'POST /api/md2png': { 'POST /api/md2png': {
description: 'Convert Markdown to PNG image', description: 'Convert Markdown to PNG image',
auth: 'X-API-Key header', auth: 'X-API-Key header OR x402 Payment-Signature header',
x402_price: x402.PRICES.md2png,
body: 'JSON: { markdown, theme?, width?, fontSize? }', body: 'JSON: { markdown, theme?, width?, fontSize? }',
}, },
'POST /api/md2html': { 'POST /api/md2html': {
@ -369,24 +433,10 @@ const server = http.createServer(async (req, res) => {
// Capture endpoint // Capture endpoint
if (parsed.pathname === '/api/capture') { if (parsed.pathname === '/api/capture') {
// Auth // Auth: x402 micropayment OR API key
const apiKey = req.headers['x-api-key'] || parsed.query.api_key; const auth = await authenticate(req, res, parsed);
if (!apiKey || !apiKeys[apiKey]) { if (!auth.ok) return;
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.'); const { keyData, settleFn } = auth;
}
const keyData = apiKeys[apiKey];
resetMonthlyUsage(keyData);
// Check monthly limit
if (keyData.used >= keyData.limit) {
return sendError(res, 429, `Monthly limit reached (${keyData.limit} captures). Upgrade your plan.`);
}
// Rate limit
if (!checkRateLimit(apiKey)) {
return sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.');
}
// Concurrency limit // Concurrency limit
if (activeTasks >= MAX_CONCURRENT) { if (activeTasks >= MAX_CONCURRENT) {
@ -436,20 +486,34 @@ const server = http.createServer(async (req, res) => {
userAgent: parsed.query.userAgent, userAgent: parsed.query.userAgent,
}); });
keyData.used++; if (keyData) {
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {}); keyData.used++;
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
}
const contentType = parsed.query.format === 'pdf' const contentType = parsed.query.format === 'pdf'
? 'application/pdf' ? 'application/pdf'
: parsed.query.format === 'jpeg' ? 'image/jpeg' : 'image/png'; : parsed.query.format === 'jpeg' ? 'image/jpeg' : 'image/png';
res.writeHead(200, { const responseHeaders = {
'Content-Type': contentType, 'Content-Type': contentType,
'Content-Length': buffer.length, 'Content-Length': buffer.length,
'X-Captures-Used': keyData.used,
'X-Captures-Limit': keyData.limit,
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
}); };
if (keyData) {
responseHeaders['X-Captures-Used'] = keyData.used;
responseHeaders['X-Captures-Limit'] = keyData.limit;
}
// Settle x402 payment after successful capture
if (settleFn) {
const settleResult = await settleFn();
if (settleResult && settleResult.headers) {
Object.assign(responseHeaders, settleResult.headers);
}
}
res.writeHead(200, responseHeaders);
res.end(buffer); res.end(buffer);
} catch (err) { } catch (err) {
stats.errors++; stats.errors++;
@ -482,18 +546,10 @@ const server = http.createServer(async (req, res) => {
// Markdown to PDF (auth required - uses Puppeteer) // Markdown to PDF (auth required - uses Puppeteer)
if (parsed.pathname === '/api/md2pdf' && req.method === 'POST') { if (parsed.pathname === '/api/md2pdf' && req.method === 'POST') {
const apiKey = req.headers['x-api-key'] || parsed.query.api_key; const auth = await authenticate(req, res, parsed);
if (!apiKey || !apiKeys[apiKey]) { if (!auth.ok) return;
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.'); const { keyData, settleFn } = auth;
}
const keyData = apiKeys[apiKey];
resetMonthlyUsage(keyData);
if (keyData.used >= keyData.limit) {
return sendError(res, 429, `Monthly limit reached (${keyData.limit}). Upgrade your plan.`);
}
if (!checkRateLimit(apiKey)) {
return sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.');
}
if (activeTasks >= MAX_CONCURRENT) { if (activeTasks >= MAX_CONCURRENT) {
return sendError(res, 503, 'Server busy. Try again in a few seconds.'); return sendError(res, 503, 'Server busy. Try again in a few seconds.');
} }
@ -518,17 +574,30 @@ const server = http.createServer(async (req, res) => {
marginRight: body.margins?.right, marginRight: body.margins?.right,
}); });
keyData.used++; if (keyData) {
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {}); keyData.used++;
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
}
res.writeHead(200, { const responseHeaders = {
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
'Content-Length': buffer.length, 'Content-Length': buffer.length,
'Content-Disposition': 'inline; filename="document.pdf"', 'Content-Disposition': 'inline; filename="document.pdf"',
'X-Captures-Used': keyData.used,
'X-Captures-Limit': keyData.limit,
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
}); };
if (keyData) {
responseHeaders['X-Captures-Used'] = keyData.used;
responseHeaders['X-Captures-Limit'] = keyData.limit;
}
if (settleFn) {
const settleResult = await settleFn();
if (settleResult && settleResult.headers) {
Object.assign(responseHeaders, settleResult.headers);
}
}
res.writeHead(200, responseHeaders);
res.end(buffer); res.end(buffer);
} finally { } finally {
activeTasks--; activeTasks--;
@ -542,18 +611,10 @@ const server = http.createServer(async (req, res) => {
// Markdown to PNG (auth required - uses Puppeteer) // Markdown to PNG (auth required - uses Puppeteer)
if (parsed.pathname === '/api/md2png' && req.method === 'POST') { if (parsed.pathname === '/api/md2png' && req.method === 'POST') {
const apiKey = req.headers['x-api-key'] || parsed.query.api_key; const auth = await authenticate(req, res, parsed);
if (!apiKey || !apiKeys[apiKey]) { if (!auth.ok) return;
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.'); const { keyData, settleFn } = auth;
}
const keyData = apiKeys[apiKey];
resetMonthlyUsage(keyData);
if (keyData.used >= keyData.limit) {
return sendError(res, 429, `Monthly limit reached (${keyData.limit}). Upgrade your plan.`);
}
if (!checkRateLimit(apiKey)) {
return sendError(res, 429, 'Rate limit exceeded. Max 10 requests per minute.');
}
if (activeTasks >= MAX_CONCURRENT) { if (activeTasks >= MAX_CONCURRENT) {
return sendError(res, 503, 'Server busy. Try again in a few seconds.'); return sendError(res, 503, 'Server busy. Try again in a few seconds.');
} }
@ -574,18 +635,31 @@ const server = http.createServer(async (req, res) => {
quality: body.quality, quality: body.quality,
}); });
keyData.used++; if (keyData) {
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {}); keyData.used++;
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
}
const imgType = body.format === 'jpeg' ? 'jpeg' : 'png'; const imgType = body.format === 'jpeg' ? 'jpeg' : 'png';
res.writeHead(200, { const responseHeaders = {
'Content-Type': `image/${imgType}`, 'Content-Type': `image/${imgType}`,
'Content-Length': buffer.length, 'Content-Length': buffer.length,
'Content-Disposition': `inline; filename="document.${imgType}"`, 'Content-Disposition': `inline; filename="document.${imgType}"`,
'X-Captures-Used': keyData.used,
'X-Captures-Limit': keyData.limit,
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
}); };
if (keyData) {
responseHeaders['X-Captures-Used'] = keyData.used;
responseHeaders['X-Captures-Limit'] = keyData.limit;
}
if (settleFn) {
const settleResult = await settleFn();
if (settleResult && settleResult.headers) {
Object.assign(responseHeaders, settleResult.headers);
}
}
res.writeHead(200, responseHeaders);
res.end(buffer); res.end(buffer);
} finally { } finally {
activeTasks--; activeTasks--;
@ -698,9 +772,16 @@ const server = http.createServer(async (req, res) => {
sendError(res, 404, 'Not found. Visit / for API documentation.'); sendError(res, 404, 'Not found. Visit / for API documentation.');
}); });
server.listen(PORT, () => { server.listen(PORT, async () => {
console.log(`Screenshot API running at http://localhost:${PORT}`); console.log(`Screenshot API running at http://localhost:${PORT}`);
console.log(`API keys: ${Object.keys(apiKeys).length} configured`); console.log(`API keys: ${Object.keys(apiKeys).length} configured`);
// Start payment polling (check every 30 seconds) // Start payment polling (check every 30 seconds)
payments.startPolling(30000); payments.startPolling(30000);
// Initialize x402 micropayment system
try {
await x402.initX402();
} catch (err) {
console.error('[x402] Failed to initialize:', err.message);
console.log('[x402] API key auth still works. x402 payments disabled.');
}
}); });

152
x402.js Normal file
View File

@ -0,0 +1,152 @@
// x402.js - x402 micropayment integration for SnapAPI
// Enables pay-per-request payments from AI agents via the x402 protocol
const { x402HTTPResourceServer, x402ResourceServer, HTTPFacilitatorClient } = require('@x402/core/server');
const { ExactEvmScheme } = require('@x402/evm/exact/server');
const WALLET_ADDRESS = '0x7483a9F237cf8043704D6b17DA31c12BfFF860DD';
// Pricing per endpoint (in USD, paid in USDC)
const PRICES = {
capture: '$0.01', // $0.01 per screenshot
md2pdf: '$0.005', // $0.005 per markdown-to-PDF
md2png: '$0.005', // $0.005 per markdown-to-PNG
};
// Accept x402 micropayments on Base (best EVM support for x402)
// Polygon subscription still available via /api/subscribe
const NETWORK = 'eip155:8453'; // Base Mainnet
// PayAI facilitator: supports Base mainnet, no API key required
const FACILITATOR_URL = 'https://facilitator.payai.network';
// HTTP adapter for raw Node.js http.IncomingMessage
class NodeHTTPAdapter {
constructor(req) {
this.req = req;
const host = req.headers.host || 'localhost';
this.parsedUrl = new URL(req.url || '/', `http://${host}`);
}
getHeader(name) { return this.req.headers[name.toLowerCase()]; }
getMethod() { return this.req.method || 'GET'; }
getPath() { return this.parsedUrl.pathname; }
getUrl() { return this.parsedUrl.href; }
getAcceptHeader() { return this.req.headers.accept || ''; }
getUserAgent() { return this.req.headers['user-agent'] || ''; }
}
let httpResourceServer = null;
let initialized = false;
function createRouteConfig(price, description, mimeType) {
return {
accepts: {
scheme: 'exact',
network: NETWORK,
payTo: WALLET_ADDRESS,
price,
maxTimeoutSeconds: 120,
},
description,
mimeType,
};
}
async function initX402() {
if (initialized) return httpResourceServer;
const facilitator = new HTTPFacilitatorClient({ url: FACILITATOR_URL });
const resourceServer = new x402ResourceServer([facilitator]);
resourceServer.register(NETWORK, new ExactEvmScheme());
const routes = {
'GET /api/capture': createRouteConfig(
PRICES.capture,
'Capture screenshot or PDF from a URL',
'image/png'
),
'POST /api/md2pdf': createRouteConfig(
PRICES.md2pdf,
'Convert Markdown to PDF',
'application/pdf'
),
'POST /api/md2png': createRouteConfig(
PRICES.md2png,
'Convert Markdown to PNG image',
'image/png'
),
};
httpResourceServer = new x402HTTPResourceServer(resourceServer, routes);
await httpResourceServer.initialize();
initialized = true;
console.log('[x402] Micropayment system initialized (PayAI facilitator)');
console.log(`[x402] Accepting USDC on Base to ${WALLET_ADDRESS}`);
console.log(`[x402] Prices: capture=${PRICES.capture}, md2pdf=${PRICES.md2pdf}, md2png=${PRICES.md2png}`);
return httpResourceServer;
}
// Check if a request has an x402 payment header
function hasX402Payment(req) {
return !!(req.headers['payment-signature'] || req.headers['x-payment']);
}
// Process an x402 payment request
// Returns: { allowed: true, settle: Function } or { allowed: false, status, headers, body }
async function processPayment(req) {
if (!httpResourceServer) {
return { allowed: false, status: 503, headers: {}, body: { error: 'x402 not initialized' } };
}
const adapter = new NodeHTTPAdapter(req);
const context = {
adapter,
path: adapter.getPath(),
method: adapter.getMethod(),
};
const result = await httpResourceServer.processHTTPRequest(context);
if (result.type === 'no-payment-required') {
return { allowed: true, settle: null };
}
if (result.type === 'payment-error') {
return {
allowed: false,
status: result.response.status,
headers: result.response.headers || {},
body: result.response.body,
isHtml: result.response.isHtml,
};
}
if (result.type === 'payment-verified') {
const settleFn = async () => {
try {
const settleResult = await httpResourceServer.processSettlement(
result.paymentPayload,
result.paymentRequirements,
result.declaredExtensions
);
return settleResult;
} catch (err) {
console.error('[x402] Settlement error:', err.message);
return null;
}
};
return { allowed: true, settle: settleFn };
}
return { allowed: false, status: 500, headers: {}, body: { error: 'Unknown payment state' } };
}
module.exports = {
initX402,
hasX402Payment,
processPayment,
PRICES,
WALLET_ADDRESS,
NETWORK,
};