Skip to content

Framework adapters

requirePayment is Express/Connect middleware. Everywhere else — Hono, Fastify, Cloudflare Workers, Next.js route handlers, Bun, Deno, anything with fetch — you build a createPaymentGate once and drive it yourself. The gate is plain, framework-free logic; the adapter is the handful of lines that read one header in and write three back out.

import { createPaymentGate } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet' })
// → PaymentGate: { challenge(url?), verify(header), describe(url?) }

createPaymentGate returns a PaymentGate — the same object requirePayment wraps. Reuse one gate per gated route. Its in-memory used-proof set is what stops a proof being redeemed twice; rebuilding it per request would lose that guard. (For multi-instance deploys, share the set with isUsed / markUsed — see Replay protection.)

Every adapter does the same three things, regardless of framework:

  1. Read the inbound proof header and pass it to gate.verify().
  2. Switch on the returned VerifyPaymentResult kind.
  3. Set the matching response header and status.
kindStatusResponse headerBodyThen
'paid'200payment-response: result.receiptHeaderyour resourceserve it
'challenge'402payment-required: result.requiredHeaderresult.challengewait for a retry
'invalid'402payment-required: result.requiredHeaderresult.challengewait for a retry
const result = await gate.verify(headerValue)
// → { kind: 'paid', receipt, receiptHeader }
// | { kind: 'challenge', challenge, requiredHeader, statusCode: 402 }
// | { kind: 'invalid', error, detail, challenge, requiredHeader, statusCode: 402 }

gate.verify() accepts string | string[] | undefined, so hand it whatever your framework gives you for the payment-signature header without massaging it first. A missing header is not an error — it returns kind: 'challenge', the first-request 402.

The Fetch API shape — Request in, Response out — covers Cloudflare Workers, Bun, Deno, and Hono in one form:

app.get('/report', async (c) => {
const r = await gate.verify(c.req.header('payment-signature'))
if (r.kind === 'paid') {
return c.json({ report: 'unlocked' }, 200, { 'payment-response': r.receiptHeader })
}
return c.json(r.challenge, 402, { 'payment-required': r.requiredHeader })
})

'challenge' and 'invalid' both return r.challenge with a 402, so a single fall-through branch handles both.

A route handler is a fetch handler — same three branches, returning a Response:

export async function GET(req: Request) {
const r = await gate.verify(req.headers.get('payment-signature') ?? undefined)
if (r.kind === 'paid') {
return Response.json({ report: 'unlocked' }, { headers: { 'payment-response': r.receiptHeader } })
}
return Response.json(r.challenge, { status: 402, headers: { 'payment-required': r.requiredHeader } })
}

Define the gate at module scope, not inside the handler, so the replay guard survives between requests.

Fastify gives you request / reply rather than fetch primitives, but the gate is identical:

fastify.get('/report', async (request, reply) => {
const r = await gate.verify(request.headers['payment-signature'])
if (r.kind === 'paid') {
reply.header('payment-response', r.receiptHeader)
return { report: 'unlocked' }
}
reply.header('payment-required', r.requiredHeader).code(402)
return r.challenge
})

The header names are exported so you never hard-code a typo. PipRail also reads and writes the legacy v1 header names, which an older standard exact client may use:

import {
HEADER_SIGNATURE, // 'payment-signature' (inbound proof)
HEADER_REQUIRED, // 'payment-required' (outbound 402 challenge)
HEADER_RESPONSE, // 'payment-response' (outbound receipt)
HEADER_SIGNATURE_V1, // 'x-payment' (legacy inbound)
HEADER_RESPONSE_V1, // 'x-payment-response' (legacy outbound)
} from '@piprail/sdk'

To accept both header generations, read the v2 name and fall back to v1 — the built-in requirePayment middleware does exactly this:

const r = await gate.verify(req.headers[HEADER_SIGNATURE] ?? req.headers[HEADER_SIGNATURE_V1])

When serving a 'paid' result, set both response headers if you want either client generation to read the receipt: payment-response and x-payment-response, both to r.receiptHeader.

requirePayment is typed against a minimal local interface, not @types/express — so the SDK needs no Express dependency and the middleware drops into Connect, Polka, or any look-alike. The shapes are exported if you wrap or adapt the middleware:

TypeWhat it is
ExpressLikeRequest{ headers, originalUrl?, url? } — just the fields the gate reads.
ExpressLikeResponse{ setHeader, status, json } — the three methods it writes through.
ExpressLikeNext(err?: unknown) => void — call to proceed on a paid request.
ExpressLikeMiddleware(req, res, next) => Promise<void> | void — the return type of requirePayment.
import { requirePayment, type ExpressLikeMiddleware } from '@piprail/sdk'
const gated: ExpressLikeMiddleware = requirePayment({
chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet',
})

This only applies when you opt into the standard exact rail, where the server settles the payment itself (own relayer or a chosen facilitator). If that server-side settle fails — relayer out of gas, facilitator down — gate.verify() throws a SettlementError. That’s not the payer’s fault: their signed authorization is still valid and unused, so return a 5xx, never a 402 (a 402 would tell them to pay again).

import { createPaymentGate, SettlementError, type VerifyPaymentResult } from '@piprail/sdk'
const gate = createPaymentGate({
chain: 'base', token: 'USDC', amount: '0.10', payTo: '0xYourWallet',
exact: { settle: 'self', relayer: { privateKey: process.env.AGENT_KEY } },
})
export async function GET(req: Request) {
let result: VerifyPaymentResult
try {
result = await gate.verify(req.headers.get('payment-signature') ?? undefined)
} catch (err) {
if (err instanceof SettlementError) {
// err.code === 'SETTLEMENT_FAILED'. Payer's authorization is still valid + unused.
return Response.json({ error: 'settlement_failed', detail: err.message }, { status: 502 })
}
throw err
}
if (result.kind === 'paid') {
return Response.json({ report: 'unlocked' }, { headers: { 'payment-response': result.receiptHeader } })
}
return Response.json(result.challenge, { status: 402, headers: { 'payment-required': result.requiredHeader } })
}

The default onchain-proof rail never settles on the server (the payer already broadcast their own transfer), so a gate without exact will never throw SettlementError from verify().