§ GUIDE · WEB SDK · @paphwey/web-sdk

Web SDK integration guide.

One package, every JavaScript runtime. Node, Next.js, Express, Fastify, Bun, Deno, Cloudflare Workers, Vercel Edge — with isomorphic types and a clean split between server credentials and browser surfaces.

Package · @paphwey/web-sdk Runtimes · Node 18+, Bun 1+, Deno 1.40+, Workers, Edge Types · Strict TypeScript, ESM + CJS

§ 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/serverPaphweyAgentClient, PaphweyServerClient. Requires a runtime with the fetch global (Node 18+).
  • @paphwey/web-sdkPaphweyWebSDK browser helpers. No secrets, safe to bundle.
  • @paphwey/web-sdk/verifyverifyOffline() 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.

If your build ever tries to bundle /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
});
OptionDefaultNotes
baseUrlRequired. https://www.paphwey.com.
apiKeyRequired. Server-only.
timeoutMs10000Per-call total timeout. Workers cap at 30s.
maxRetries3429/5xx only with full jitter.
fetchglobalThis.fetchInject a runtime-specific fetch (e.g. undici, fetch from Workers bindings).
kvCachenullOptional. 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 });
}
Route segment config. Add 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 });
  },
};
Do not construct the client at module top-level in Workers if you need per-env isolation. Build it inside 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) });
}
Rule of thumb. The browser never calls 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 /server is marked as a client component or bundled to the browser.
  • Content-Security-Policy allows connect-src to your backend only — the browser never needs www.paphwey.com.
  • JWKS cache is wired to a runtime-appropriate store (KV on Workers, Map on Node, built-in on Bun/Deno).
  • Edge / Worker routes have an explicit runtime declaration.
  • Confirmation JWK is pinned on the session after first verification.
  • Retry policy covers 429 and 5xx with full jitter, and sends an Idempotency-Key on every createDelegation / presentDelegation call — 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 as sk_test_… against the same endpoints, so test traffic is visibly distinct from production.

Any runtime

One SDK. Every JavaScript you run.