Skip to main content
@faremeter/fetch wraps the global fetch with automatic 402 payment handling. Unlike @faremeter/rides, you configure handlers and options explicitly.
pnpm add @faremeter/fetch

Basic usage

import { Keypair } from "@solana/web3.js"
import { wrap } from "@faremeter/fetch"
import { createPaymentHandler } from "@faremeter/payment-solana/exact"
import { createLocalWallet } from "@faremeter/wallet-solana"

const keypair = Keypair.fromSecretKey(new Uint8Array(keypairBytes))
const wallet = await createLocalWallet("devnet", keypair)
const handler = createPaymentHandler(wallet, wallet.mint)

const fetchWithPayment = wrap(fetch, {
  handlers: [handler],
})

const response = await fetchWithPayment("https://api.example.com/resource")

wrap

import { wrap } from "@faremeter/fetch"

const wrappedFetch = wrap(phase2Fetch: typeof fetch, options: WrapOpts): typeof fetch
Returns a new fetch function that intercepts 402 responses and handles payment automatically.

WrapOpts

OptionTypeDefaultDescription
handlersPaymentHandler[]RequiredPayment handlers to use for 402 responses.
payerChooser(execers: PaymentExecer[]) => Promise<PaymentExecer>chooseFirstAvailableSelects which payer to use when multiple can fulfill a requirement.
phase1Fetchtypeof fetchSame as phase2FetchFetch function for the initial request (before payment).
retryCountnumber2Number of retries after payment (3 total attempts).
initialRetryDelaynumber100Starting backoff delay in milliseconds.
returnPaymentFailurebooleanfalseReturn the 402 response instead of throwing on payment failure.

Phase 1 and Phase 2 fetch

The wrapper uses two fetch calls per payment:
  1. Phase 1: The initial request that discovers the 402 response.
  2. Phase 2: The retry with the X-PAYMENT header attached.
By default, both use the same fetch function. You can provide a separate phase1Fetch if the initial request needs different configuration (e.g., different headers or timeouts).

Payer selection

When multiple handlers can fulfill a payment requirement, the payerChooser function selects one.
import { wrap, chooseFirstAvailable } from "@faremeter/fetch"

const wrappedFetch = wrap(fetch, {
  handlers: [solanaHandler, evmHandler],
  payerChooser: chooseFirstAvailable,
})
chooseFirstAvailable is the default. It picks the first handler that returns a PaymentExecer.

Custom payer chooser

const preferSolana = (execers: PaymentExecer[]) => {
  const solana = execers.find((e) => e.requirements.network.startsWith("solana"))
  return solana ?? execers[0]
}

const wrappedFetch = wrap(fetch, {
  handlers: [solanaHandler, evmHandler],
  payerChooser: preferSolana,
})

Error handling

If all payment attempts fail, wrap throws a WrappedFetchError:
import { WrappedFetchError } from "@faremeter/fetch"
import { getLogger } from "@faremeter/logs"

const logger = await getLogger(["my-app"])

try {
  const response = await wrappedFetch("https://api.example.com/resource")
} catch (error) {
  if (error instanceof WrappedFetchError) {
    logger.error(`Payment failed: ${error.message}, status: ${error.response.status}`)
  }
}
Alternatively, set returnPaymentFailure: true to receive the 402 response instead of throwing:
const wrappedFetch = wrap(fetch, {
  handlers: [handler],
  returnPaymentFailure: true,
})

const response = await wrappedFetch("https://api.example.com/resource")
if (response.status === 402) {
  // Payment failed, handle accordingly
}

Retry behavior

Failed payment attempts are retried with exponential backoff:
  • Default: 2 retries (3 total attempts)
  • Backoff starts at 100ms and doubles each retry
  • Only retries when the 402 payment flow fails, not on other HTTP errors

Testing with mocks

@faremeter/fetch exports a mock namespace for testing payment flows without real network calls.

responseFeeder

import { mock } from "@faremeter/fetch"
responseFeeder(responses) takes an array of Response objects or fetch-like functions and returns a mock fetch. Each call to the returned function consumes the next item in the array.

Basic test pattern

Queue a 402 response followed by a success response to simulate a full payment flow:
import { mock, wrap } from "@faremeter/fetch"

const mockFetch = mock.responseFeeder([
  // Phase 1: server returns 402
  new Response(JSON.stringify({ requirements: [/* ... */] }), {
    status: 402,
    headers: { "Content-Type": "application/json" },
  }),
  // Phase 2: server accepts payment header
  new Response(JSON.stringify({ data: "paid content" }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  }),
])

const wrappedFetch = wrap(mockFetch, {
  handlers: [testHandler],
})

const response = await wrappedFetch("https://api.example.com/resource")
// response.status === 200

Separate phase 1 and phase 2 mocks

Use phase1Fetch to mock each phase independently:
const phase1Mock = mock.responseFeeder([
  new Response(JSON.stringify({ requirements: [/* ... */] }), { status: 402 }),
])

const phase2Mock = mock.responseFeeder([
  new Response("OK", { status: 200 }),
])

const wrappedFetch = wrap(phase2Mock, {
  handlers: [testHandler],
  phase1Fetch: phase1Mock,
})

Using fetch-like functions

Instead of static Response objects, pass functions for dynamic responses:
const mockFetch = mock.responseFeeder([
  async (input: RequestInfo | URL, init?: RequestInit) => {
    const url = input.toString()
    if (url.includes("/paid")) {
      return new Response(null, { status: 402 })
    }
    return new Response("free", { status: 200 })
  },
])

Further reading