diff --git a/examples/.env.example b/examples/.env.example index 589c7508b..7e1019782 100644 --- a/examples/.env.example +++ b/examples/.env.example @@ -8,4 +8,6 @@ ANTHROPIC_API_KEY=sk-ant-your-anthropic-key-here # OPENAI_API_KEY=sk-your-openai-key-here # Test Server URL (for axios/fetch/node-http examples) -DUMMY_SERVER_URL=https://x402-demo.sapiom.ai +# `npm start` / `npm run free` starts the local demo server automatically. +# Override this only when you want to point the examples at another compatible server. +DUMMY_SERVER_URL=http://localhost:3101 diff --git a/examples/README.md b/examples/README.md index a2c000717..38da47dad 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,13 +36,13 @@ SAPIOM_API_KEY=your-key-from-step-1 SAPIOM_API_URL=https://api.sapiom.ai # For axios/fetch/node-http examples: -DUMMY_SERVER_URL=https://x402-demo.sapiom.ai +DUMMY_SERVER_URL=http://localhost:3101 # For langchain examples only: ANTHROPIC_API_KEY=sk-ant-your-key ``` -> **Note:** The `DUMMY_SERVER_URL` points to a public demo server that simulates paid API endpoints. The default URL in `.env.example` is ready to use. +> **Note:** `npm start`, `npm run free`, and `npm run full` for the axios, fetch, and node-http examples automatically start a local demo server at `http://localhost:3101`. Override `DUMMY_SERVER_URL` only when you want to point the examples at another compatible server. ### 3. Run an example @@ -56,6 +56,7 @@ npm run full # All endpoints (requires Sapiom balance for payments) ``` **Start with `npm start`** - this uses free endpoints and doesn't require a balance. You can test: + - Basic SDK integration - Authorization tracking - Usage rules @@ -90,11 +91,11 @@ On the second request, you should see an `AuthorizationDeniedError` - this means ## Available Examples -| Example | Package | Best for | -|---------|---------|----------| -| `axios/` | `@sapiom/axios` | Existing Axios codebases | -| `fetch/` | `@sapiom/fetch` | Native fetch API users | -| `node-http/` | `@sapiom/node-http` | Raw Node.js HTTP | +| Example | Package | Best for | +| -------------------- | --------------------------- | ----------------------------------- | +| `axios/` | `@sapiom/axios` | Existing Axios codebases | +| `fetch/` | `@sapiom/fetch` | Native fetch API users | +| `node-http/` | `@sapiom/node-http` | Raw Node.js HTTP | | `langchain-classic/` | `@sapiom/langchain-classic` | LangChain v0.3.x with tool wrappers | > **Note:** The `langchain/` example is for LangChain v1.x. Use `langchain-classic/` if you're on LangChain v0.3.x. @@ -103,7 +104,9 @@ Start with `axios/` or `fetch/` - they're the simplest to understand. ## About the Demo Server -The examples connect to a demo server that simulates real-world APIs with payment requirements. This server implements the [x402 payment protocol](https://www.x402.org/) - an HTTP standard where APIs can require micropayments. +The axios, fetch, and node-http examples connect to a demo server that simulates real-world API endpoints. `npm start`, `npm run free`, and `npm run full` start the local demo server automatically, so the examples do not depend on the public `x402-demo.sapiom.ai` host being reachable. + +The optional paid flow (`npm run full`) exercises the same endpoint shapes locally. To test against a live compatible x402 server instead, set `DUMMY_SERVER_URL` before running the example. x402 is an HTTP standard where APIs can require micropayments. **How it works:** @@ -115,6 +118,7 @@ The examples connect to a demo server that simulates real-world APIs with paymen You don't need to understand the x402 protocol - the Sapiom SDK handles it transparently. Your code just makes normal HTTP requests. The demo server simulates a marketing platform with: + - **CRM endpoints** - Customer data (free, but requires authorization) - **SMS endpoints** - Send messages (paid per message) - **Analytics endpoints** - Campaign metrics (paid + authorization) @@ -125,18 +129,18 @@ The demo server (`DUMMY_SERVER_URL`) provides these endpoints: ### Free endpoints (no balance required) -| Endpoint | Auth | Payment | Description | -|----------|------|---------|-------------| -| `GET /api/public/time` | No | No | Current server time | -| `GET /api/public/status` | No | No | Server health check | -| `GET /api/crm/customers` | Yes | No | Fetch customer list | +| Endpoint | Auth | Payment | Description | +| ------------------------ | ---- | ------- | ------------------- | +| `GET /api/public/time` | No | No | Current server time | +| `GET /api/public/status` | No | No | Server health check | +| `GET /api/crm/customers` | Yes | No | Fetch customer list | ### Paid endpoints (require balance) -| Endpoint | Auth | Payment | Description | -|----------|------|---------|-------------| -| `POST /api/sms` | No | $0.0075 | Send SMS message | -| `POST /api/campaigns/analytics` | Yes | $0.05 | Get campaign analytics | +| Endpoint | Auth | Payment | Description | +| ------------------------------- | ---- | ------- | ---------------------- | +| `POST /api/sms` | No | $0.0075 | Send SMS message | +| `POST /api/campaigns/analytics` | Yes | $0.05 | Get campaign analytics | The Sapiom SDK handles all authorization and payment automatically - your code just makes normal HTTP requests. @@ -145,15 +149,24 @@ The Sapiom SDK handles all authorization and payment automatically - your code j ## Troubleshooting **"SAPIOM_API_KEY environment variable is required"** + - Make sure you copied `.env.example` to `.env` and filled in your API key **Connection refused / timeout** -- Check that `DUMMY_SERVER_URL` is correct and the test server is running + +- For `npm start`, `npm run free`, and `npm run full`, check that port `3101` is available; those scripts start the local demo server automatically. +- If you override `DUMMY_SERVER_URL`, check that the server is reachable and implements the demo endpoints listed above. + +**403 from x402-demo.sapiom.ai** + +- The public demo host can be blocked by Cloudflare or unavailable from some environments. Use the default local demo server for free examples, or point `DUMMY_SERVER_URL` at another compatible demo server. **AuthorizationDeniedError on first request** + - Check your Rules in the dashboard - you may have a restrictive policy **Nothing showing in dashboard** + - Verify your `SAPIOM_API_KEY` is correct - Check the console output for errors diff --git a/examples/axios/README.md b/examples/axios/README.md index 5cf826fb7..7d3104ba8 100644 --- a/examples/axios/README.md +++ b/examples/axios/README.md @@ -22,6 +22,8 @@ npm run free # Same as above npm run full # All endpoints including paid (requires balance) ``` +`npm start`, `npm run free`, and `npm run full` start the local demo server automatically and set `DUMMY_SERVER_URL=http://localhost:3101` for the run. Override `DUMMY_SERVER_URL` only when you want to test against another compatible server. + ## What This Shows - Wrap your existing Axios instance with `withSapiom()` diff --git a/examples/axios/package.json b/examples/axios/package.json index b3fc7faaa..047b719ed 100644 --- a/examples/axios/package.json +++ b/examples/axios/package.json @@ -4,9 +4,9 @@ "private": true, "description": "Sapiom SDK example using Axios", "scripts": { - "start": "npx tsx index-free.ts", - "free": "npx tsx index-free.ts", - "full": "npx tsx index.ts" + "start": "node ../run-local-demo.mjs index-free.ts", + "free": "node ../run-local-demo.mjs index-free.ts", + "full": "node ../run-local-demo.mjs index.ts" }, "dependencies": { "@sapiom/axios": "latest", diff --git a/examples/fetch/README.md b/examples/fetch/README.md index 79660fafe..96aa192c4 100644 --- a/examples/fetch/README.md +++ b/examples/fetch/README.md @@ -22,6 +22,8 @@ npm run free # Same as above npm run full # All endpoints including paid (requires balance) ``` +`npm start`, `npm run free`, and `npm run full` start the local demo server automatically and set `DUMMY_SERVER_URL=http://localhost:3101` for the run. Override `DUMMY_SERVER_URL` only when you want to test against another compatible server. + ## What This Shows - Create a Sapiom-wrapped fetch function with `createFetch()` diff --git a/examples/fetch/package.json b/examples/fetch/package.json index dcf54e1ec..71bf3c28d 100644 --- a/examples/fetch/package.json +++ b/examples/fetch/package.json @@ -4,9 +4,9 @@ "private": true, "description": "Sapiom SDK example using native Fetch API", "scripts": { - "start": "npx tsx index-free.ts", - "free": "npx tsx index-free.ts", - "full": "npx tsx index.ts" + "start": "node ../run-local-demo.mjs index-free.ts", + "free": "node ../run-local-demo.mjs index-free.ts", + "full": "node ../run-local-demo.mjs index.ts" }, "dependencies": { "@sapiom/fetch": "latest", diff --git a/examples/local-demo-server.mjs b/examples/local-demo-server.mjs new file mode 100644 index 000000000..66550ed3f --- /dev/null +++ b/examples/local-demo-server.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +import http from "node:http"; + +const port = Number.parseInt(process.env.DUMMY_SERVER_PORT ?? "3101", 10); +const startedAt = Date.now(); + +const customers = [ + { + id: "cus_demo_enterprise_1", + name: "Acme Corp", + email: "ops@acme.example", + phone: "+15550100001", + segment: "enterprise", + revenue: 250000, + }, + { + id: "cus_demo_enterprise_2", + name: "Globex", + email: "team@globex.example", + phone: "+15550100002", + segment: "enterprise", + revenue: 180000, + }, + { + id: "cus_demo_enterprise_3", + name: "Initech", + email: "hello@initech.example", + phone: "+15550100003", + segment: "enterprise", + revenue: 125000, + }, +]; + +function writeJson(res, status, body) { + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + }); + res.end(JSON.stringify(body)); +} + +function readJson(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + if (!body) { + resolve({}); + return; + } + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL( + req.url ?? "/", + `http://${req.headers.host ?? `localhost:${port}`}`, + ); + + if (req.method === "OPTIONS") { + res.writeHead(204, { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,POST,OPTIONS", + "access-control-allow-headers": + "content-type,authorization,x-sapiom-transaction-id", + }); + res.end(); + return; + } + + if (req.method === "GET" && url.pathname === "/api/public/time") { + writeJson(res, 200, { + time: new Date().toISOString(), + timezone: "UTC", + }); + return; + } + + if (req.method === "GET" && url.pathname === "/api/public/status") { + writeJson(res, 200, { + status: "ok", + version: "local-demo", + uptime: (Date.now() - startedAt) / 1000, + }); + return; + } + + if (req.method === "GET" && url.pathname === "/api/crm/customers") { + const limit = Number.parseInt( + url.searchParams.get("limit") ?? String(customers.length), + 10, + ); + const segment = url.searchParams.get("segment"); + const filtered = segment + ? customers.filter((customer) => customer.segment === segment) + : customers; + writeJson(res, 200, { + customers: filtered.slice( + 0, + Number.isFinite(limit) ? limit : filtered.length, + ), + total: filtered.length, + }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/sms") { + const body = await readJson(req); + writeJson(res, 200, { + messageId: `sms_demo_${Date.now()}`, + status: "queued", + price: 0.0075, + to: body.phone, + }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/campaigns/analytics") { + const body = await readJson(req); + const sent = 3; + const opened = 2; + writeJson(res, 200, { + campaignId: body.campaignId ?? "demo-campaign", + name: "Local Demo Campaign", + status: "completed", + metrics: { + sent, + delivered: sent, + opened, + clicked: 1, + conversions: 1, + openRate: Math.round((opened / sent) * 100), + }, + }); + return; + } + + writeJson(res, 404, { error: "not_found", path: url.pathname }); +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`Local Sapiom demo server listening at http://localhost:${port}`); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + server.close(() => process.exit(0)); + }); +} diff --git a/examples/node-http/README.md b/examples/node-http/README.md index 3741f2c55..a502fdbf9 100644 --- a/examples/node-http/README.md +++ b/examples/node-http/README.md @@ -22,6 +22,8 @@ npm run free # Same as above npm run full # All endpoints including paid (requires balance) ``` +`npm start`, `npm run free`, and `npm run full` start the local demo server automatically and set `DUMMY_SERVER_URL=http://localhost:3101` for the run. Override `DUMMY_SERVER_URL` only when you want to test against another compatible server. + ## What This Shows - Create a Sapiom HTTP client with `createClient()` diff --git a/examples/node-http/package.json b/examples/node-http/package.json index 427498965..0a45319d5 100644 --- a/examples/node-http/package.json +++ b/examples/node-http/package.json @@ -4,9 +4,9 @@ "private": true, "description": "Sapiom SDK example using native Node.js HTTP", "scripts": { - "start": "npx tsx index-free.ts", - "free": "npx tsx index-free.ts", - "full": "npx tsx index.ts" + "start": "node ../run-local-demo.mjs index-free.ts", + "free": "node ../run-local-demo.mjs index-free.ts", + "full": "node ../run-local-demo.mjs index.ts" }, "dependencies": { "@sapiom/node-http": "latest", diff --git a/examples/run-local-demo.mjs b/examples/run-local-demo.mjs new file mode 100644 index 000000000..1485595b7 --- /dev/null +++ b/examples/run-local-demo.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const script = process.argv[2]; +const scriptArgs = process.argv.slice(3); + +if (!script) { + console.error("Usage: node ../run-local-demo.mjs [...args]"); + process.exit(2); +} + +const port = process.env.DUMMY_SERVER_PORT ?? "3101"; +const dummyServerUrl = `http://localhost:${port}`; +const serverPath = path.join(__dirname, "local-demo-server.mjs"); + +const server = spawn(process.execPath, [serverPath], { + cwd: __dirname, + env: { ...process.env, DUMMY_SERVER_PORT: port }, + stdio: ["ignore", "pipe", "inherit"], +}); + +let ready = false; +server.stdout.on("data", (chunk) => { + const text = chunk.toString(); + process.stdout.write(text); + if (text.includes("Local Sapiom demo server listening")) ready = true; +}); + +async function waitForServer() { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (ready) return; + try { + const response = await fetch(`${dummyServerUrl}/api/public/status`); + if (response.ok) return; + } catch { + // Server is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + `local demo server did not become ready at ${dummyServerUrl}`, + ); +} + +try { + await waitForServer(); +} catch (error) { + server.kill("SIGTERM"); + console.error(error.message); + process.exit(1); +} + +const child = spawn("npx", ["tsx", script, ...scriptArgs], { + cwd: process.cwd(), + env: { + ...process.env, + DUMMY_SERVER_URL: dummyServerUrl, + }, + stdio: "inherit", + shell: process.platform === "win32", +}); + +const [code, signal] = await once(child, "exit"); +server.kill("SIGTERM"); +if (signal) process.kill(process.pid, signal); +process.exit(code ?? 1);