@ferry402/sdk
v0.2.1
Published
Accept x402 payments from users on other chains. Non-custodial escrow settlement with Hedera Consensus Service as the clearing ledger.
Maintainers
Readme
@ferry402/sdk
Charge for an API call before your Express route handles it. @ferry402/sdk
is an x402 middleware — x402 is an open standard that
uses HTTP's 402 Payment Required status to ask a caller for a stablecoin
payment, then lets the request through once that payment is verified. Each
payment settles into a non-custodial Escrow smart contract (one per chain)
and is journaled to Hedera, used here as a clearing ledger: an ordered,
independently-auditable record of who paid whom.
This package is ferry402's merchant-side (and payer-side) half. See the repo root README for the full system — this package, the facilitator, and the contracts.
Install
npm install @ferry402/sdkESM only ("type": "module", no require condition). Requires Node >= 20.18.3
and Express ^4.18.0 || ^5.0.0 as a peer dependency.
Quickstart: guard a route
import express from 'express'
import { ferry402 } from '@ferry402/sdk'
import type { Ferry402Config } from '@ferry402/sdk'
const config: Ferry402Config = {
price: '$0.01',
accept: ['base-sepolia'],
settleTo: 'hedera',
merchant: '0.0.123456', // your Hedera account id
// merchantEvm/escrows/assets are Partial<Record<SupportedChain, ...>> - an
// entry is only required for each chain you actually list in `accept`.
// ferry402(config) throws at construction if one is missing.
merchantEvm: { 'base-sepolia': '0xYourPayoutAddress' },
facilitator: 'http://localhost:4000',
escrows: { 'base-sepolia': '0xDeployedEscrowAddress' },
assets: { 'base-sepolia': '0xUSDCAddress' },
secret: process.env.FERRY402_SECRET!, // >= 32 bytes, shared across every instance
}
const app = express()
app.get('/premium-endpoint', ferry402(config), (_req, res) => {
res.json({ data: 'this only serves once payment verifies' })
})An unpaid request gets a 402 with an accepts array; a request carrying a
valid X-PAYMENT header falls through to your handler via next().
ferry402() only verifies — it does not call /settle. Actually
collecting the payment and journaling it to HCS is your handler's job,
immediately after next() runs. See the root README's "How it works" and
@ferry402/facilitator (the companion service that verifies and settles
payments on-chain) for the settlement half of that wiring.
Paying a route: createPaymentHeader
No stock x402 client (x402-fetch, x402-axios, or anything built
against plain [email protected]) can pay a ferry402 route. ferry402 binds the
EIP-3009 authorization's nonce to the merchant it was issued for —
nonce = keccak256(abi.encode(merchantEvm, paymentId)) — instead of letting
the payer mint random bytes. This way a signed payment can never be
redirected to a merchant other than the one who issued the 402.
Upstream x402 clients generate a random nonce instead, so ferry402 never
recognizes it as matching any challenge it issued. The payment fails closed
with invalid_payment: the server has no way to tell a self-invented nonce
apart from a genuinely expired or mismatched one, so it doesn't claim to.
Use createPaymentHeader instead — it derives the same nonce ferry402
expects and signs the ReceiveWithAuthorization for you:
import { createPaymentHeader } from '@ferry402/sdk'
import { privateKeyToAccount } from 'viem/accounts'
// 1. Hit the route with no payment; read the 402's `accepts` array.
const res = await fetch('http://merchant.example/premium-endpoint')
const { accepts } = await res.json()
const requirement = accepts[0] // pick the chain you want to pay on
// 2. Sign a payment header against it.
const account = privateKeyToAccount(process.env.PAYER_PRIVATE_KEY as `0x${string}`)
const header = await createPaymentHeader(requirement, account, {
tokenName: 'USDC', // the asset's EIP-712 domain name — read it from the
tokenVersion: '2', // token contract, or your deployment notes; it
chainId: 84532, // varies by deployment and can't be assumed
})
// 3. Retry with the header.
const paid = await fetch('http://merchant.example/premium-endpoint', {
headers: { 'X-PAYMENT': header },
})signer is any object with an address and a viem-compatible
signTypedData — a viem PrivateKeyAccount (as above) works directly; no
viem dependency is required by this package itself.
API reference
Merchant side
| Export | Signature |
|---|---|
| ferry402 | (config: Ferry402Config, options?: Ferry402Options) => RequestHandler |
| buildRequirements | (config, resource: string, options?: BuildRequirementsOptions) => PaymentRequirements[] |
| parsePrice | (price: string) => string — "$0.01" to atomic units ("10000") |
| InMemoryConsumedNonceStore | class implements ConsumedNonceStore — the default, per-process replay store |
| Ferry402Locals | type for res.locals.x402 once a payment verifies — see below |
ferry402() is the whole integration for most users. buildRequirements is
exported so you can issue a 402 yourself (a non-Express framework, say)
without reimplementing the accepts array.
res.locals.x402 (Ferry402Locals). Once a payment verifies, your route
handler can read res.locals.x402 as Ferry402Locals:
{ payload, requirements, payer, release }. release(): Promise<void> lets
the handler free the nonce ferry402 already consumed, if the handler's own
/settle call then fails — call it from that failure branch; the happy path
never needs it.
Payer side
| Export | Signature |
|---|---|
| createPaymentHeader | (requirement: PaymentRequirements, signer: EIP3009Signer, options: CreatePaymentHeaderOptions) => Promise<string> |
Nonce derivation
The binding the Escrow contract enforces on-chain, and the single
TypeScript implementation of it.
| Export | Signature |
|---|---|
| computeNonce | (merchantEvm: Hex, paymentId: Hex) => Hex — keccak256(abi.encode(merchantEvm, paymentId)) |
| normalizeNonce | (nonce: string) => Hex — canonicalise before comparison |
| normalizeAddress | (address: string) => Hex — same, for addresses, so a checksum difference never changes a derivation |
computeNonce is pinned to golden vectors in both this package and the
Foundry suite, so the TypeScript and Solidity derivations cannot drift apart
without a test going red.
Stateless challenges
Used internally by ferry402(). Exported for anyone issuing or validating
challenges outside the middleware.
| Export | Signature |
|---|---|
| deriveChallenge | (secret, merchantEvm, resource, nowMs?) => DerivedChallenge |
| matchChallenge | (secret, merchantEvm, resource, presentedNonce, nowMs?) => DerivedChallenge \| undefined |
| derivePaymentId | (secret, merchantEvm, resource, bucket) => Hex |
| timeBucket | (nowMs: number, bucketSeconds?: number) => number |
| generatePaymentId | () => Hex — random id, for callers not using derivation |
| assertValidSecret | (secret: unknown) => asserts secret is string |
| MIN_SECRET_BYTES | 32 |
| TIME_BUCKET_SECONDS | 300 |
matchChallenge checks the current bucket and the previous one, so the
usable payment window is up to 2 * TIME_BUCKET_SECONDS (10 minutes).
Types
Ferry402Config, Ferry402Options, Ferry402Locals, SupportedChain,
PaymentRequirements, BuildRequirementsOptions, ConsumedNonceStore,
EIP3009Signer, CreatePaymentHeaderOptions, DerivedChallenge.
Configuration reference
| Field | Notes |
|---|---|
| secret | Required, >= 32 bytes. Same value on every instance. See notes below. |
| escrows | Per-chain Escrow address. Required for each chain in accept. See notes below. |
| merchant | Your Hedera account id, e.g. "0.0.123456" — the clearing-layer identity the HCS journal keys on. |
| merchantEvm | Per-chain payout address. Required for each chain in accept. See notes below. |
| accept | Chains this merchant takes payment on. Facilitator supports only 2 of 4 values today — see notes below. |
| assets | Per-chain USDC (or other supported asset) contract address. Required for each chain in accept. |
| facilitator | Base URL of the facilitator this middleware calls /verify against. |
| price | Decimal-dollar string, e.g. "$0.01". USDC (6 decimals) only in v1. |
secret—ferry402(config)throws at construction if it's missing or under 32 bytes. It must be identical across every process/instance serving this merchant: that's what lets two instances validate each other's challenges with no shared store. Generate it withopenssl rand -hex 32and never commit it.escrows,merchantEvm,assetsare eachPartial<Record<SupportedChain, ...>>— you only need an entry for chains you actually list inaccept;ferry402(config)throws at construction, naming the chain, if one is missing. This package doesn't validateescrowsagainst a live facilitator, but note: a facilitator started with no matchingescrowsentry for a chain rejects every payment on that chain, fail-closed — see@ferry402/facilitator.merchantvs.merchantEvm— these are different identifiers:merchantis a Hedera account id,merchantEvmis an EVM address and one of the two preimages of the nonce binding. Mixing them up breaks the nonce derivation for that chain.accept— the type allows'base' | 'base-sepolia' | 'polygon' | 'polygon-amoy', but@ferry402/facilitatoronly servesbaseandbase-sepoliatoday. Advertising a Polygon network produces a 402 no payment can satisfy: the facilitator returnsinvalid_network.
Known limitations
- Replay protection is per-process by default.
InMemoryConsumedNonceStoreclears on restart, losing protection for the derivation window still open at that moment, and it is not shared across horizontally-scaled instances. Pass your ownConsumedNonceStore(Redis, a database, ...) viaferry402(config, { consumedNonceStore })for a multi-instance deployment. ferry402()verifies; it does not settle. Calling/settleand writing the HCS journal entry is your handler's job, immediately afternext().- Keep settlements sequential per facilitator. See
docs/troubleshooting.md. - Testnet, and unaudited. See
SECURITY.md.
More
- Architecture — how a payment moves, and the trust model.
- Deployments — live addresses and every settlement transaction.
- Troubleshooting — real failure modes and fixes.
examples/demo— a runnable end-to-end demo using this package from npm.
License
MIT — see LICENSE.
