Migration Guide

August 13, 2026 ยท View on GitHub

v4 to v5

Breaking changes: IETF Payment credential format, X402Facilitator interface, RailVerifyResult shape.

Why

v5 is a security-hardening release. A full review of the payment flow found that stateless credentials were not bound to the route they paid for, top-up proofs could be replayed for extra credit, and currency mismatches could resolve to a zero cost. v5 closes these at the protocol level, which requires format changes.

What to change

IETF Payment clients: charge credentials now carry a resource field bound into the HMAC challenge. Credentials minted before v5 are rejected โ€” clients must request a fresh challenge. Session top-up payment hashes are single-use; retrying a top-up requires a new invoice.

Custom x402 facilitators: verify() now receives the full payment requirements as a second argument. Check amount, network, asset and payTo against it (the built-in rail also enforces these around your callback):

// v4
verify: async (payload) => { /* ... */ }

// v5
verify: async (payload, requirements) => { /* ... */ }

Custom payment rails: RailVerifyResult gains amountPaid. Populate it so the engine can reject underpaid settlements; omitting it disables that check for your rail.

Behaviour changes (non-breaking)

  • Request paths are normalised before pricing lookup (/api/joke/ and /api/joke are priced identically). If you relied on trailing-slash variants being unpriced, set strictPricing: true and price the canonical path.
  • A one-time startup warning is printed when pricing is configured and strictPricing is off.
  • x402 (USD) credentials on sats-only routes (and vice versa) are now challenged instead of proxied at zero cost.

v2 to v3

Breaking change: RequestEvent and ChallengeEvent no longer include the clientIp field.

Why

v3 introduced privacy-by-design IP handling. IP addresses are now one-way hashed with a daily-rotating salt before any processing. Exposing raw IPs via event hooks contradicted this principle, so the field was removed.

What to change

If your onRequest or onChallenge callbacks reference event.clientIp, remove those references:

// v2
onRequest: (event) => {
  console.log(`${event.endpoint} from ${event.clientIp}`)
}

// v3
onRequest: (event) => {
  console.log(`${event.endpoint} | ${event.satsDeducted} sats`)
}

If you need client identification for analytics, use the getClientIp callback at the Booth level to hash or anonymise IPs yourself before they reach your logging pipeline.

Other v3 changes (non-breaking)

  • IP addresses are one-way hashed in free-tier tracking (no raw IPs stored in memory)
  • Free-tier IP hashing uses a daily-rotating salt; hashes cannot be correlated across days

v1 to v2

Breaking change: The old unauthenticated relay backend was replaced with a proper NWC backend using NIP-44 encryption.

What changed

v1v2
import { albyBackend } from '@forgesworn/toll-booth/backends/alby'import { nwcBackend } from '@forgesworn/toll-booth/backends/nwc'
AlbyConfigNwcConfig
albyBackend(config)nwcBackend(config)
Unauthenticated JSON relay transportNIP-44 encrypted Nostr relay transport
Required allowInsecureRelay: trueSecure by default

Why

The v1 backend used an unauthenticated JSON relay transport that required an explicit allowInsecureRelay: true opt-in. This was a stopgap; the v2 backend uses authenticated NIP-44 encryption and the standard NIP-47 contract.

What to change

// v1
import { albyBackend } from '@forgesworn/toll-booth/backends/alby'

const backend = albyBackend({
  nwcUrl: 'nostr+walletconnect://...',
  allowInsecureRelay: true,
})

// v2
import { nwcBackend } from '@forgesworn/toll-booth/backends/nwc'
import { closeSync, fstatSync, openSync, readSync } from 'node:fs'

function loadNwcUri(file: string): string {
  const descriptor = openSync(file, 'r')
  let bytes: Buffer | undefined
  try {
    const info = fstatSync(descriptor)
    if (!info.isFile() || info.size === 0 || info.size > 8192) throw new Error('Invalid NWC URI file')
    if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error('Run chmod 600 on the NWC URI file')
    bytes = Buffer.allocUnsafe(info.size)
    let offset = 0
    while (offset < bytes.length) {
      const count = readSync(descriptor, bytes, offset, bytes.length - offset, null)
      if (count === 0) throw new Error('NWC URI file changed while it was being read')
      offset += count
    }
    const extra = Buffer.allocUnsafe(1)
    try {
      if (readSync(descriptor, extra, 0, 1, null) !== 0) throw new Error('NWC URI file changed while it was being read')
    } finally {
      extra.fill(0)
    }
    return bytes.toString('utf8').trim()
  } finally {
    bytes?.fill(0)
    closeSync(descriptor)
  }
}

const backend = nwcBackend({
  nwcUrl: loadNwcUri(process.env.NWC_URI_FILE!),
})

The nwcUrl format is the same. Remove allowInsecureRelay as it is no longer needed, and keep the bearer URI in a private secret file rather than source code or a raw environment value.