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:
parent
9361f9e4e2
commit
da6dc035ed
4344
package-lock.json
generated
4344
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,9 @@
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@x402/core": "^2.3.0",
|
||||
"@x402/evm": "^2.3.0",
|
||||
"@x402/express": "^2.3.0",
|
||||
"marked": "^17.0.1",
|
||||
"puppeteer-core": "^24.0.0"
|
||||
},
|
||||
|
||||
217
server.js
217
server.js
@ -6,6 +6,7 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { marked } = require('marked');
|
||||
const payments = require('./payments');
|
||||
const x402 = require('./x402');
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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
|
||||
let stats = { total_captures: 0, screenshots: 0, pdfs: 0, md_conversions: 0, errors: 0 };
|
||||
const ANALYTICS_FILE = path.join(__dirname, 'analytics.json');
|
||||
@ -279,7 +326,8 @@ const server = http.createServer(async (req, res) => {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'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();
|
||||
return;
|
||||
@ -305,13 +353,27 @@ const server = http.createServer(async (req, res) => {
|
||||
if (parsed.pathname === '/api') {
|
||||
sendJson(res, 200, {
|
||||
service: 'SnapAPI - Document Generation Suite',
|
||||
version: '2.0.0',
|
||||
description: 'Screenshots, PDFs, and Markdown conversion API',
|
||||
built_by: 'AI Agent (Claude) - transparent about AI authorship',
|
||||
version: '3.0.0',
|
||||
description: 'Screenshots, PDFs, and Markdown conversion API with x402 micropayments',
|
||||
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: {
|
||||
'GET /api/capture': {
|
||||
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: {
|
||||
url: 'Target URL (required)',
|
||||
format: 'png (default), jpeg, or pdf',
|
||||
@ -326,12 +388,14 @@ const server = http.createServer(async (req, res) => {
|
||||
},
|
||||
'POST /api/md2pdf': {
|
||||
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? }',
|
||||
},
|
||||
'POST /api/md2png': {
|
||||
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? }',
|
||||
},
|
||||
'POST /api/md2html': {
|
||||
@ -369,24 +433,10 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
// Capture endpoint
|
||||
if (parsed.pathname === '/api/capture') {
|
||||
// Auth
|
||||
const apiKey = req.headers['x-api-key'] || parsed.query.api_key;
|
||||
if (!apiKey || !apiKeys[apiKey]) {
|
||||
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.');
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
// Auth: x402 micropayment OR API key
|
||||
const auth = await authenticate(req, res, parsed);
|
||||
if (!auth.ok) return;
|
||||
const { keyData, settleFn } = auth;
|
||||
|
||||
// Concurrency limit
|
||||
if (activeTasks >= MAX_CONCURRENT) {
|
||||
@ -436,20 +486,34 @@ const server = http.createServer(async (req, res) => {
|
||||
userAgent: parsed.query.userAgent,
|
||||
});
|
||||
|
||||
keyData.used++;
|
||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||
if (keyData) {
|
||||
keyData.used++;
|
||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||
}
|
||||
|
||||
const contentType = parsed.query.format === 'pdf'
|
||||
? 'application/pdf'
|
||||
: parsed.query.format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
||||
|
||||
res.writeHead(200, {
|
||||
const responseHeaders = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': buffer.length,
|
||||
'X-Captures-Used': keyData.used,
|
||||
'X-Captures-Limit': keyData.limit,
|
||||
'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);
|
||||
} catch (err) {
|
||||
stats.errors++;
|
||||
@ -482,18 +546,10 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
// Markdown to PDF (auth required - uses Puppeteer)
|
||||
if (parsed.pathname === '/api/md2pdf' && req.method === 'POST') {
|
||||
const apiKey = req.headers['x-api-key'] || parsed.query.api_key;
|
||||
if (!apiKey || !apiKeys[apiKey]) {
|
||||
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.');
|
||||
}
|
||||
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.');
|
||||
}
|
||||
const auth = await authenticate(req, res, parsed);
|
||||
if (!auth.ok) return;
|
||||
const { keyData, settleFn } = auth;
|
||||
|
||||
if (activeTasks >= MAX_CONCURRENT) {
|
||||
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,
|
||||
});
|
||||
|
||||
keyData.used++;
|
||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||
if (keyData) {
|
||||
keyData.used++;
|
||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
const responseHeaders = {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': buffer.length,
|
||||
'Content-Disposition': 'inline; filename="document.pdf"',
|
||||
'X-Captures-Used': keyData.used,
|
||||
'X-Captures-Limit': keyData.limit,
|
||||
'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);
|
||||
} finally {
|
||||
activeTasks--;
|
||||
@ -542,18 +611,10 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
// Markdown to PNG (auth required - uses Puppeteer)
|
||||
if (parsed.pathname === '/api/md2png' && req.method === 'POST') {
|
||||
const apiKey = req.headers['x-api-key'] || parsed.query.api_key;
|
||||
if (!apiKey || !apiKeys[apiKey]) {
|
||||
return sendError(res, 401, 'Invalid or missing API key. Include X-API-Key header.');
|
||||
}
|
||||
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.');
|
||||
}
|
||||
const auth = await authenticate(req, res, parsed);
|
||||
if (!auth.ok) return;
|
||||
const { keyData, settleFn } = auth;
|
||||
|
||||
if (activeTasks >= MAX_CONCURRENT) {
|
||||
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,
|
||||
});
|
||||
|
||||
keyData.used++;
|
||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||
if (keyData) {
|
||||
keyData.used++;
|
||||
fs.writeFile(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2), () => {});
|
||||
}
|
||||
|
||||
const imgType = body.format === 'jpeg' ? 'jpeg' : 'png';
|
||||
res.writeHead(200, {
|
||||
const responseHeaders = {
|
||||
'Content-Type': `image/${imgType}`,
|
||||
'Content-Length': buffer.length,
|
||||
'Content-Disposition': `inline; filename="document.${imgType}"`,
|
||||
'X-Captures-Used': keyData.used,
|
||||
'X-Captures-Limit': keyData.limit,
|
||||
'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);
|
||||
} finally {
|
||||
activeTasks--;
|
||||
@ -698,9 +772,16 @@ const server = http.createServer(async (req, res) => {
|
||||
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(`API keys: ${Object.keys(apiKeys).length} configured`);
|
||||
// Start payment polling (check every 30 seconds)
|
||||
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
152
x402.js
Normal 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,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user