§ 01 · Install
The SDK is published on npm as
@paphwey/web-sdk. It ships both ESM
and CJS builds, and declares runtime-specific
entry points (workerd,
deno, browser) so your
bundler picks the right one automatically.
npm install @paphwey/web-sdk # or pnpm add @paphwey/web-sdk bun add @paphwey/web-sdk yarn add @paphwey/web-sdk
Entry points:
@paphwey/web-sdk/server—PaphweyAgentClient,PaphweyServerClient. Requires a runtime with thefetchglobal (Node 18+).@paphwey/web-sdk—PaphweyWebSDKbrowser helpers. No secrets, safe to bundle.@paphwey/web-sdk/verify—verifyOffline()and JWKS helpers. Runs anywhere with WebCrypto.
§ 02 · Server / browser split
The model is simple: credentials stay on
the server. The browser never sees
sk_live_*. The two roles you will
wire are:
Server role
Opens delegations & verifies outcomes.
Lives in your API route, route handler, or serverless function. Imports from @paphwey/web-sdk/server. Holds the API key.
Browser role
Renders the approval surface.
Receives the approval_url from your server, renders it as a QR code or SMS handoff, and polls a server route for status. Imports from @paphwey/web-sdk.
/server into the browser, stop.
The SDK will throw at import time, but it is
faster to fix the bundler. Use the
"server-only" package in Next.js, or
guard with import.meta.env.SSR in
Vite.
§ 03 · Configure the client
import { PaphweyAgentClient } from "@paphwey/web-sdk/server";
export const paphwey = new PaphweyAgentClient({
baseUrl: process.env.PAPHWEY_BASE_URL!,
apiKey: process.env.PAPHWEY_API_KEY!,
timeoutMs: 10_000,
maxRetries: 3,
userAgent: "merchant-web/2.4 (+https://merchant.example)",
fetch: globalThis.fetch, // override for Workers / Edge runtimes
});
| Option | Default | Notes |
|---|---|---|
baseUrl | — | Required. https://www.paphwey.com. |
apiKey | — | Required. Server-only. |
timeoutMs | 10000 | Per-call total timeout. Workers cap at 30s. |
maxRetries | 3 | 429/5xx only with full jitter. |
fetch | globalThis.fetch | Inject a runtime-specific fetch (e.g. undici, fetch from Workers bindings). |
kvCache | null | Optional. Supply a KVNamespace or Map for JWKS caching on stateless runtimes. |
§ 04 · Step 1 — Create a delegation
const delegation = await paphwey.createDelegation({
principalEmail: "alice@example.com",
provider: "openai",
agentId: "shopping-agent",
allowedScopes: ["purchase"],
allowedChallengeTypes: ["HIGH_VALUE_PURCHASE_REQUIRED"],
maxAmountMinor: 2500,
currency: "GBP",
validUntil: new Date(Date.now() + 14 * 864e5).toISOString(),
metadata: { channel: "web-chat" },
});
console.log(delegation.delegationId, delegation.status);
§ 05 · Step 2 — Present the delegation
const challenge = await paphwey.presentDelegation(delegation.delegationId, {
challengeType: "HIGH_VALUE_PURCHASE_REQUIRED",
audience: "merchant.example",
payload: {
actionContext: {
scope: "purchase",
amountMinor: 1500,
currency: "GBP",
merchant: "acme.example",
},
minimumAssurance: 10,
},
callbackUrl: "https://merchant.example/api/paphwey/hook",
ttlSeconds: 900,
});
// Hand challenge.approvalUrl to the BROWSER, not to the agent.
return Response.json({ approvalUrl: challenge.approvalUrl,
challengeId: challenge.challengeId });
§ 06 · Step 3 — Verify the outcome
const outcome = await paphwey.verifyOutcome({
attestationToken: tokenFromAgent,
audience: "merchant.example",
expectedDelegationId: delegationId,
expectedAction: { scope: "purchase", amountMinor: 1500, currency: "GBP" },
});
if (!outcome.valid) return new Response("invalid", { status: 403 });
session.set("cnfJwkThumbprint", outcome.cnfJwkThumbprint);
session.set("assurance", outcome.assuranceScore);
§ 07 · Framework patterns
The three-call triad is the same everywhere. What changes is where you put the client, how you get the token to the browser, and which runtime edge cases bite.
Next.js · App Router (RSC + route handlers)
Put the client behind the server-only
boundary. Expose two route handlers — one to
present, one to verify.
// app/lib/paphwey.ts
import "server-only";
import { PaphweyAgentClient } from "@paphwey/web-sdk/server";
export const paphwey = new PaphweyAgentClient({
baseUrl: process.env.PAPHWEY_BASE_URL!,
apiKey: process.env.PAPHWEY_API_KEY!,
});
// app/api/paphwey/present/route.ts
import { NextRequest, NextResponse } from "next/server";
import { paphwey } from "@/app/lib/paphwey";
export async function POST(req: NextRequest) {
const body = await req.json();
const challenge = await paphwey.presentDelegation(body.delegationId, {
challengeType: "HIGH_VALUE_PURCHASE_REQUIRED",
audience: "merchant.example",
payload: { actionContext: body.action, minimumAssurance: 10 },
});
return NextResponse.json({ approvalUrl: challenge.approvalUrl });
}
// app/api/paphwey/verify/route.ts
import { paphwey } from "@/app/lib/paphwey";
export async function POST(req: Request) {
const { token, delegationId } = await req.json();
const outcome = await paphwey.verifyOutcome({
attestationToken: token,
audience: "merchant.example",
expectedDelegationId: delegationId,
});
if (!outcome.valid) return new Response("invalid", { status: 403 });
return Response.json({ assurance: outcome.assuranceScore });
}
export const runtime = "nodejs" to
route handlers that use the server client unless
you have configured the Edge runtime (see § Vercel Edge).
Express / Fastify (classic Node)
import express from "express";
import { PaphweyAgentClient } from "@paphwey/web-sdk/server";
const paphwey = new PaphweyAgentClient({
baseUrl: process.env.PAPHWEY_BASE_URL!,
apiKey: process.env.PAPHWEY_API_KEY!,
});
const app = express();
app.use(express.json());
app.post("/api/paphwey/present", async (req, res) => {
const challenge = await paphwey.presentDelegation(req.body.delegationId, {
challengeType: "HIGH_VALUE_PURCHASE_REQUIRED",
audience: "merchant.example",
payload: { actionContext: req.body.action, minimumAssurance: 10 },
});
res.json({ approvalUrl: challenge.approvalUrl });
});
app.post("/api/paphwey/verify", async (req, res) => {
const outcome = await paphwey.verifyOutcome({
attestationToken: req.body.token,
audience: "merchant.example",
expectedDelegationId: req.body.delegationId,
});
if (!outcome.valid) return res.status(403).send("invalid");
res.json({ assurance: outcome.assuranceScore });
});
app.listen(3000);
Fastify is identical modulo syntax — register one
plugin that decorates
fastify.paphwey, then use it from
your route handlers.
Bun (Bun.serve + Elysia)
Bun has a native fetch and
WebCrypto, so the SDK works with zero
polyfills. The only wrinkle is that cold starts
are fast enough that connection pooling matters
less than in Node.
import { PaphweyAgentClient } from "@paphwey/web-sdk/server";
const paphwey = new PaphweyAgentClient({
baseUrl: Bun.env.PAPHWEY_BASE_URL!,
apiKey: Bun.env.PAPHWEY_API_KEY!,
});
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/api/paphwey/verify" && req.method === "POST") {
const { token, delegationId } = await req.json();
const outcome = await paphwey.verifyOutcome({
attestationToken: token,
audience: "merchant.example",
expectedDelegationId: delegationId,
});
return outcome.valid
? Response.json({ ok: true })
: new Response("invalid", { status: 403 });
}
return new Response("not found", { status: 404 });
},
});
Elysia users: inject the client via
.decorate("paphwey", paphwey) and
pull it from context.
Deno / Hono
The SDK's deno entry point uses
Deno's standard fetch and
crypto. Import via npm specifier.
import { Hono } from "hono";
import { PaphweyAgentClient } from "npm:@paphwey/web-sdk/server";
const paphwey = new PaphweyAgentClient({
baseUrl: Deno.env.get("PAPHWEY_BASE_URL")!,
apiKey: Deno.env.get("PAPHWEY_API_KEY")!,
});
const app = new Hono();
app.post("/api/paphwey/verify", async (c) => {
const { token, delegationId } = await c.req.json();
const outcome = await paphwey.verifyOutcome({
attestationToken: token,
audience: "merchant.example",
expectedDelegationId: delegationId,
});
return outcome.valid ? c.json({ ok: true }) : c.text("invalid", 403);
});
Deno.serve(app.fetch);
Cloudflare Workers
Workers run on workerd with a strict
25 ms CPU budget on the free tier. Two adaptations
matter: always pass a KV cache for
JWKS, and inject the bound
fetch if you are behind a service
binding.
// wrangler.toml
// [vars] PAPHWEY_BASE_URL = "https://www.paphwey.com"
// [[kv_namespaces]] binding = "JWKS_KV" id = "..."
// Put PAPHWEY_API_KEY in secrets via `wrangler secret put`.
import { PaphweyAgentClient } from "@paphwey/web-sdk/server";
export interface Env {
PAPHWEY_BASE_URL: string;
PAPHWEY_API_KEY: string;
JWKS_KV: KVNamespace;
}
export default {
async fetch(req: Request, env: Env): Promise {
const paphwey = new PaphweyAgentClient({
baseUrl: env.PAPHWEY_BASE_URL,
apiKey: env.PAPHWEY_API_KEY,
kvCache: env.JWKS_KV,
});
if (req.method === "POST" && new URL(req.url).pathname === "/verify") {
const { token, delegationId } = await req.json();
const outcome = await paphwey.verifyOutcome({
attestationToken: token,
audience: "merchant.example",
expectedDelegationId: delegationId,
});
return outcome.valid
? Response.json({ ok: true })
: new Response("invalid", { status: 403 });
}
return new Response("not found", { status: 404 });
},
};
fetch()
(it's cheap) or lazy-singleton with
globalThis.
Vercel Edge / middleware
Vercel Edge uses the Web Standard runtime. The SDK
works unchanged — just mark the route as Edge and
remember that Node built-ins are unavailable. Use
verifyOffline() in middleware if you
want to gate requests without a round-trip.
// app/api/paphwey/verify/route.ts
export const runtime = "edge";
import { PaphweyAgentClient } from "@paphwey/web-sdk/server";
const paphwey = new PaphweyAgentClient({
baseUrl: process.env.PAPHWEY_BASE_URL!,
apiKey: process.env.PAPHWEY_API_KEY!,
});
export async function POST(req: Request) {
const { token, delegationId } = await req.json();
const outcome = await paphwey.verifyOutcome({
attestationToken: token,
audience: "merchant.example",
expectedDelegationId: delegationId,
});
return outcome.valid
? Response.json({ ok: true })
: new Response("invalid", { status: 403 });
}
// middleware.ts — gate a protected route with offline verification
import { NextResponse, type NextRequest } from "next/server";
import { verifyOffline } from "@paphwey/web-sdk/verify";
export const config = { matcher: "/agent/:path*" };
export async function middleware(req: NextRequest) {
const token = req.headers.get("x-paphwey-attestation");
if (!token) return new NextResponse("missing", { status: 401 });
const outcome = await verifyOffline({
baseUrl: process.env.PAPHWEY_BASE_URL!,
attestationToken: token,
audience: "merchant.example",
});
if (!outcome.valid) return new NextResponse("invalid", { status: 403 });
const res = NextResponse.next();
res.headers.set("x-paphwey-assurance", String(outcome.assuranceScore));
return res;
}
Remix / SvelteKit
Both frameworks expose action + loader primitives
that map cleanly to the triad. Build the client
once in a module with a
.server suffix (Remix) or in
src/lib/server/ (SvelteKit).
// app/paphwey.server.ts (Remix)
import { PaphweyAgentClient } from "@paphwey/web-sdk/server";
export const paphwey = new PaphweyAgentClient({
baseUrl: process.env.PAPHWEY_BASE_URL!,
apiKey: process.env.PAPHWEY_API_KEY!,
});
// app/routes/api.paphwey.verify.tsx
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { paphwey } from "~/paphwey.server";
export async function action({ request }: ActionFunctionArgs) {
const { token, delegationId } = await request.json();
const outcome = await paphwey.verifyOutcome({
attestationToken: token,
audience: "merchant.example",
expectedDelegationId: delegationId,
});
if (!outcome.valid) throw new Response("invalid", { status: 403 });
return json({ assurance: outcome.assuranceScore });
}
SvelteKit: the same pattern inside
+server.ts. Import from
$env/static/private for the key and
base URL.
§ 08 · Browser helpers
In the browser you never hold the API key. The browser helpers only deal with the approval surface — render the QR, handle the SMS handoff, and poll your own backend for status.
import { PaphweyWebSDK } from "@paphwey/web-sdk";
const sdk = new PaphweyWebSDK({
// No secrets here. Just the path to YOUR backend routes.
presentEndpoint: "/api/paphwey/present",
verifyEndpoint: "/api/paphwey/verify",
});
// 1. Ask your backend to open a challenge.
const { approvalUrl, challengeId } = await sdk.present({
delegationId,
action: { scope: "purchase", amountMinor: 1500, currency: "GBP" },
});
// 2. Render the QR. Or SMS the URL. Or present a deep-link button.
sdk.renderApprovalQR(approvalUrl, document.getElementById("approval-qr")!);
// 3. Poll until approval or expiry.
const outcome = await sdk.waitForOutcome({ challengeId, timeoutMs: 120_000 });
if (outcome.status === "approved") {
// Forward the attestation token to your backend to re-verify.
await fetch("/api/paphwey/accept",
{ method: "POST", body: JSON.stringify(outcome) });
}
www.paphwey.com directly.
Every browser->Paphwey request goes through
your backend. This keeps the API key
private and lets your server enforce its own
policy before opening a challenge.
§ 09 · Offline verification
verifyOffline() checks the JWS
against the gateway JWKS without a round-trip.
Works in every runtime with WebCrypto (all the
ones listed above). Cache JWKS with the KV /
in-memory cache appropriate to your runtime.
import { verifyOffline } from "@paphwey/web-sdk/verify";
const outcome = await verifyOffline({
baseUrl: "https://www.paphwey.com",
attestationToken,
audience: "merchant.example",
expectedDelegationId: delegationId,
jwksCache: myRuntimeCache, // Map in Node; KVNamespace in Workers
maxJwksAgeMs: 5 * 60_000,
});
§ 10 · Error handling
All SDK errors derive from
PaphweyError. Narrow with
instanceof.
import {
PaphweyError, PaphweyPolicyError, PaphweyVerificationError,
PaphweyRateLimitError, PaphweyAuthError,
} from "@paphwey/web-sdk/server";
try {
await paphwey.createDelegation(input);
} catch (err) {
if (err instanceof PaphweyPolicyError) return new Response("ceiling", { status: 422 });
if (err instanceof PaphweyAuthError) return new Response("auth", { status: 401 });
if (err instanceof PaphweyRateLimitError) return new Response("busy",
{ status: 503, headers: { "Retry-After": String(err.retryAfterSeconds) } });
if (err instanceof PaphweyVerificationError) return new Response("invalid", { status: 403 });
throw err; // bubble unknowns
}
§ 11 · Pre-production checklist
- API key is read from a runtime secret store (Vercel env, Worker secret, k8s secret) — never in the browser bundle.
- No route that imports
/serveris marked as a client component or bundled to the browser. - Content-Security-Policy allows
connect-srcto your backend only — the browser never needswww.paphwey.com. - JWKS cache is wired to a runtime-appropriate store (KV on Workers,
Mapon Node, built-in on Bun/Deno). - Edge / Worker routes have an explicit
runtimedeclaration. - Confirmation JWK is pinned on the session after first verification.
- Retry policy covers 429 and 5xx with full jitter, and sends an
Idempotency-Keyon everycreateDelegation/presentDelegationcall — Paphwey caches the success response for 24h per(tenant, endpoint, key)so retries dedupe at the gateway. - E2E smoke tests run against a real Paphwey tenant on every deploy. For sandbox runs, point at a tenant with
is_sandbox=true— its credential is issued assk_test_…against the same endpoints, so test traffic is visibly distinct from production.