x402 payments
Charge for APIs and content with fee-free HBD micropayments using the x402 payment protocol on Hive.
The @hiveio/x402 package brings the x402 payment protocol to Hive. Any API or content endpoint can ask for an HBD micropayment before it answers. Hive transfers have no fees and Hive produces a block every 3 seconds, so payments arrive in full and fast.
The package is MIT licensed. The source is on GitHub. This page describes version 0.2.x.
How it works
Section titled “How it works”The protocol uses HTTP status 402 Payment Required to negotiate a payment between a client and a server:
- The client requests a protected resource.
- The server answers 402 with payment requirements (amount, recipient, network). They are in the JSON body and, base64 encoded, in the
x-paymentresponse header. - The client builds and signs an HBD transfer, but does not broadcast it.
- The client retries the request with the signed transaction in the
x-paymentrequest header. - The server middleware sends the payment to a facilitator, first to
/verify, then to/settle. - The facilitator checks the signature against the payer’s on-chain active key, broadcasts the transfer and marks the nonce as spent.
- The server delivers the content and adds an
x-payment-responseheader with the settlement result.
- Client
- Server
- Facilitator
- Client to Server: GET /premium
- Server to Client: 402 + payment requirements
- Client: Sign an HBD transfer, don't broadcast
- Client to Server: GET /premium with x‑payment
- Server to Facilitator: POST /verify
- Facilitator to Server: isValid: true
- Server to Facilitator: POST /settle
- Facilitator: Broadcast, mark nonce spent
- Facilitator to Server: success, txId
- Server to Client: 200 + content, x‑payment‑response
At a glance
Section titled “At a glance”| Feature | Details |
|---|---|
| Currency | HBD (Hive Backed Dollars), designed to track 1 USD |
| Transaction fees | None |
| Block time | 3 seconds |
| Protocol versions | x402 v1 and v2 wire formats (middleware answers with v2 by default) |
| Middleware | Express, Next.js App Router, Hono |
| Client | Auto-pay fetch wrapper, standalone signing, browser building blocks |
| Facilitator | Public instance at https://x402.ecency.com, or run your own |
| Nonce storage | SQLite (default) or Redis |
| Node.js | 20 or newer |
Installation
Section titled “Installation”npm install @hiveio/x402# orpnpm add @hiveio/x402Dependencies. The package installs @hiveio/dhive, better-sqlite3 and express. ioredis is an optional dependency for the Redis nonce store.
Peer dependencies. express (^4.21.0 || ^5.0.0) and hono (^4.0.0) are optional peers. Install the one your server uses.
The package uses subpath exports, so you import only what you need:
import { ... } from "@hiveio/x402/types"; // constants, types, encode/decode helpersimport { ... } from "@hiveio/x402/client"; // HiveX402Client, signing helpersimport { ... } from "@hiveio/x402/middleware"; // Express paywall()import { ... } from "@hiveio/x402/middleware/nextjs"; // Next.js withPaywall()import { ... } from "@hiveio/x402/middleware/hono"; // Hono honoPaywall()import { ... } from "@hiveio/x402/facilitator"; // createFacilitator(), nonce storesProtect an API endpoint
Section titled “Protect an API endpoint”All three middlewares take the same options:
| Option | Type | Description |
|---|---|---|
amount |
string or price function |
HBD amount such as "0.050 HBD", or a function that computes it per request |
receivingAccount |
string |
Hive account that receives the payment |
facilitatorUrl |
string |
Base URL of the facilitator |
description |
string (optional) |
Resource description shown to the payer |
mimeType |
string (optional) |
MIME type of the response |
extra |
object or function (optional) | Extra fields included in the payment requirements |
x402Version |
1 or 2 (optional) |
Wire format of the 402 response. Default: 2 |
Express
Section titled “Express”import express from "express";import { paywall } from "@hiveio/x402/middleware";
const app = express();
app.get( "/premium", paywall({ amount: "0.050 HBD", receivingAccount: "your-hive-account", facilitatorUrl: "https://x402.ecency.com", description: "Premium API access", }), (req, res) => { // req.payer: the Hive account that paid // req.txId: the on-chain transaction ID res.json({ message: "Premium content", payer: req.payer }); });Next.js App Router
Section titled “Next.js App Router”import { withPaywall } from "@hiveio/x402/middleware/nextjs";
async function handler(req: Request, context: { payer: string; txId: string }) { return Response.json({ message: "Premium content", payer: context.payer });}
export const GET = withPaywall( { amount: "0.050 HBD", receivingAccount: "your-hive-account", facilitatorUrl: "https://x402.ecency.com", }, handler);import { Hono } from "hono";import { honoPaywall } from "@hiveio/x402/middleware/hono";
const app = new Hono();
app.get( "/premium", honoPaywall({ amount: "0.050 HBD", receivingAccount: "your-hive-account", facilitatorUrl: "https://x402.ecency.com", }), (c) => { const payer = c.get("payer"); const txId = c.get("txId"); return c.json({ message: "Premium content", payer, txId }); });Dynamic pricing
Section titled “Dynamic pricing”amount and extra also accept a function. It receives a PricingContext with the requested resource path and the framework’s raw request (Express or Web Request, Hono Context). Async functions work too.
import { paywall } from "@hiveio/x402/middleware";import type { PricingContext } from "@hiveio/x402/types";import type { Request } from "express";
app.get( "/api/ai", paywall({ amount: ({ raw: req }: PricingContext<Request>) => req.query.model === "premium" ? "0.500 HBD" : "0.050 HBD", extra: { tiers: { basic: "0.050 HBD", premium: "0.500 HBD" } }, receivingAccount: "your-hive-account", facilitatorUrl: "https://x402.ecency.com", }), handler);The middleware computes the price again when the paid request comes in, so the facilitator always checks the payment against the server’s price, not the client’s.
Responses from the middleware
Section titled “Responses from the middleware”| Status | When |
|---|---|
402 |
No x-payment header (body and x-payment header carry the requirements), or verification or settlement failed ({ error, reason }) |
400 |
The x-payment header could not be decoded |
502 |
The facilitator returned an error or timed out |
500 |
Any other error while processing the payment |
On success the handler runs and the response carries x-payment-response: the base64 encoded JSON SettleResponse (success, txId, blockNum, payer).
import { base64ToUtf8, type SettleResponse } from "@hiveio/x402/types";
const header = res.headers.get("x-payment-response");if (header) { const settlement: SettleResponse = JSON.parse(base64ToUtf8(header)); console.log(settlement.txId, settlement.blockNum);}Pay for a resource (client)
Section titled “Pay for a resource (client)”Auto-pay client
Section titled “Auto-pay client”HiveX402Client wraps fetch and handles 402 responses for you. It is meant for servers, scripts and AI agents that hold an active key.
import { HiveX402Client } from "@hiveio/x402/client";
const client = new HiveX402Client({ account: "alice", activeKey: "5K...", // WIF active key maxPayment: 0.1, // max HBD per request (default: 1.000)});
// Pays transparently if the server returns 402const response = await client.fetch("https://api.example.com/premium");const data = await response.json();If the requested amount is above maxPayment, fetch throws instead of paying.
Manual signing
Section titled “Manual signing”For more control, parse the requirements and sign yourself:
import { parseRequirements, signPayment } from "@hiveio/x402/client";import { HEADER_PAYMENT } from "@hiveio/x402/types";
const res = await fetch("https://api.example.com/premium");
if (res.status === 402) { // Reads the x-payment header and detects v1 or v2 const { requirements, x402Version, resource } = parseRequirements(res); if (!requirements) throw new Error("No Hive payment option offered");
const paymentHeader = await signPayment({ account: "alice", activeKey: "5K...", requirements, x402Version, resource, });
const paid = await fetch("https://api.example.com/premium", { headers: { [HEADER_PAYMENT]: paymentHeader }, });}Sign in the browser with a wallet extension
Section titled “Sign in the browser with a wallet extension”In a web app you should not ask for the user’s active key. Build the unsigned transaction with buildPaymentTransaction (it uses Web Crypto, with no Node.js dependencies), have a Hive Keychain compatible extension sign it without broadcasting, then encode it with encodePaymentPayload.
This follows the flow the open-source Ecency blog app uses for its hosting payments.
import { parseRequirements, buildPaymentTransaction, encodePaymentPayload,} from "@hiveio/x402/client";import { HEADER_PAYMENT, HIVE_NETWORK, isV1Requirements, type PaymentRequired,} from "@hiveio/x402/types";
function signWithKeychain(username: string, tx: object): Promise<object> { return new Promise((resolve, reject) => { const keychain = (window as any).hive_keychain; if (!keychain?.requestSignTx) { reject(new Error("No extension with requestSignTx support found")); return; } // Signs with the active key and returns the transaction, without broadcasting it keychain.requestSignTx(username, tx, "Active", (resp: any) => { if (!resp.success) return reject(new Error(resp.message || "Signing cancelled")); resolve(typeof resp.result === "string" ? JSON.parse(resp.result) : resp.result); }); });}
// parseRequirements reads only the x-payment header. Cross-origin, the browser hides// that header unless the server exposes it, so fall back to the 402 JSON body,// which carries the same requirements.async function readRequirements(res: Response): Promise<ReturnType<typeof parseRequirements>> { const fromHeader = parseRequirements(res); if (fromHeader.requirements) return fromHeader;
const body = (await res.json().catch(() => null)) as PaymentRequired | null; if (!body || (body.x402Version !== 1 && body.x402Version !== 2)) return fromHeader; return { requirements: body.accepts.find((r) => r.network === HIVE_NETWORK) ?? null, x402Version: body.x402Version, resource: body.x402Version === 2 ? body.resource : undefined, };}
export async function payAndFetch(url: string, username: string) { const first = await fetch(url); if (first.status !== 402) return first;
const { requirements, x402Version, resource } = await readRequirements(first); if (!requirements) throw new Error("No Hive payment option offered");
// 1. Unsigned HBD transfer with a random nonce in the memo (x402:<nonce>) const { transaction, nonce } = await buildPaymentTransaction({ account: username, requirements, });
// 2. The user approves the transfer in their wallet extension const signedTransaction = await signWithKeychain(username, transaction);
// 3. Encode for the x-payment header (v2 echoes the accepted requirements back) const header = encodePaymentPayload({ signedTransaction: signedTransaction as any, nonce, x402Version, ...(isV1Requirements(requirements) ? {} : { accepted: requirements, resource }), });
return fetch(url, { headers: { [HEADER_PAYMENT]: header } });}A few things to know:
- If your page and the paid API are on different origins, the API must send
Access-Control-Expose-Headers: x-payment, x-payment-response. Otherwise the browser hides those headers from your code:parseRequirementsfinds nothing (hence the body fallback above) and you can’t read the settlement result fromx-payment-response. - The signer must return the exact transaction it was given, plus signatures. Hive Keychain and Keychain compatible extensions do this with
requestSignTx. Extensions withoutrequestSignTxcannot be used. - HiveAuth is not supported by the Ecency blog app for x402: its protocol accepts operations, not a prebuilt transaction, so the signed result would not match the transaction built for the payment.
- The transaction expires after 60 seconds, or earlier if the requirements say so, so sign promptly.
- Show the amount (
getRequiredAmount(requirements)) and the recipient (requirements.payTo) to the user before you call the extension.
Facilitator
Section titled “Facilitator”The facilitator verifies and settles payments for resource servers. It checks the secp256k1 signature against the payer’s on-chain active keys, checks the amount, recipient, memo nonce and expiry, then broadcasts the transfer. Ecency runs a public facilitator at https://x402.ecency.com.
Endpoints
Section titled “Endpoints”| Method | Path | Description |
|---|---|---|
| GET | /health |
Health check, returns { "status": "ok" } |
| GET | /supported-networks |
Returns { "networks": ["hive:mainnet"] } |
| POST | /verify |
Verify a signed payment without broadcasting. Body: VerifyRequest, returns VerifyResponse |
| POST | /settle |
Verify, broadcast and mark the nonce spent. Body: SettleRequest, returns SettleResponse |
| GET | /metrics, /stats |
Only when you enable metrics on your own facilitator (see below) |
curl https://x402.ecency.com/supported-networks# {"networks":["hive:mainnet"]}Run your own
Section titled “Run your own”createFacilitator() returns an Express app:
import { createFacilitator, RedisNonceStore } from "@hiveio/x402/facilitator";import Redis from "ioredis";
const app = createFacilitator({ // All options are optional nonceStore: new RedisNonceStore({ client: new Redis() }), // dbPath: "nonces.db", // SQLite file, used only when nonceStore is not set // hiveClient: new Client([...]), // a dhive Client; by default a node pool with failover rateLimit: { max: 60, windowMs: 60_000 }, // per IP; false disables it // enableMetrics: true, // adds /metrics (JSON) and /stats (HTML) // metricsToken: "secret", // Bearer token for those routes (or METRICS_TOKEN env); without it they are open});
app.listen(Number(process.env.FACILITATOR_PORT ?? 4020));| Option | Default | Description |
|---|---|---|
nonceStore |
SQLite store | Any object with isSpent(nonce) and markSpent(nonce) (sync or async) |
dbPath |
"nonces.db" |
SQLite path for the default store |
hiveClient |
built-in node pool | A @hiveio/dhive Client to use instead |
rateLimit |
{ max: 60, windowMs: 60000 } |
In-memory per-IP limit, or false to disable |
enableMetrics |
false |
Serve /metrics and /stats |
metricsToken |
METRICS_TOKEN env |
Bearer token for the metrics routes. If neither is set, the routes are open to anyone. |
If you enable metrics, also set metricsToken (or METRICS_TOKEN). Only then do /metrics and /stats require
Authorization: Bearer <token> (other requests get 403). Without a token they are public.
When you run the facilitator module directly (node node_modules/@hiveio/x402/dist/facilitator/index.js), it listens on FACILITATOR_PORT, default 4020.
Nonce stores
Section titled “Nonce stores”Every payment memo carries a random nonce. The facilitator refuses a nonce it has already settled, which stops replays.
import { SqliteNonceStore, RedisNonceStore } from "@hiveio/x402/facilitator";
// SQLite (default, zero config). Use ":memory:" for tests.const sqlite = new SqliteNonceStore("nonces.db");
// Redis: works with ioredis or node-redis clientsconst redis = new RedisNonceStore({ client: redisClient, prefix: "x402:nonce:", // default ttlSeconds: 86400, // default: 24 hours});Use Redis when you run more than one facilitator instance, so they share spent nonces.
Types reference
Section titled “Types reference”The package supports both x402 wire formats. v1 keeps the amount, resource and expiry in each requirement. v2 moves the resource into the envelope and uses amount plus maxTimeoutSeconds.
// v1interface PaymentRequirementsV1 { x402Version: 1; scheme: "exact"; network: "hive:mainnet"; maxAmountRequired: string; // "0.050 HBD" resource: string; description?: string; mimeType?: string; payTo: string; // Hive account validBefore: string; // ISO 8601 extra?: Record<string, unknown>;}
interface PaymentRequiredV1 { x402Version: 1; accepts: PaymentRequirementsV1[];}
// v2 (middleware default)interface PaymentRequirementsV2 { scheme: "exact"; network: "hive:mainnet"; asset: string; // "HBD" amount: string; // "0.050 HBD" payTo: string; maxTimeoutSeconds: number; extra: Record<string, unknown>;}
interface PaymentRequiredV2 { x402Version: 2; error?: string; resource: { url: string; description?: string; mimeType?: string }; accepts: PaymentRequirementsV2[]; extensions?: Record<string, unknown>;}
type PaymentRequirements = PaymentRequirementsV1 | PaymentRequirementsV2;type PaymentRequired = PaymentRequiredV1 | PaymentRequiredV2;
// Sent in the x-payment request header (base64 JSON)interface PaymentPayloadV1 { x402Version: 1; scheme: "exact"; network: "hive:mainnet"; payload: { signedTransaction: SignedTransaction; nonce: string };}
interface PaymentPayloadV2 { x402Version: 2; accepted: PaymentRequirementsV2; resource?: { url: string; description?: string; mimeType?: string }; payload: { signedTransaction: SignedTransaction; nonce: string }; extensions?: Record<string, unknown>;}
// Facilitator requests and responsesinterface VerifyRequest { paymentPayload: PaymentPayload; paymentRequirements: PaymentRequirements; validBefore?: string; // for v2, since v2 requirements have no validBefore}
interface VerifyResponse { isValid: boolean; invalidReason?: string; payer?: string;}
type SettleRequest = VerifyRequest;
interface SettleResponse { success: boolean; txId?: string; blockNum?: number; errorReason?: string; payer?: string;}Constants and helpers
Section titled “Constants and helpers”import { HEADER_PAYMENT, // "x-payment" HEADER_PAYMENT_RESPONSE, // "x-payment-response" HIVE_NETWORK, // "hive:mainnet" X402_VERSION, // 1 X402_VERSION_V2, // 2 encodePayment, decodePayment, encodePaymentRequired, decodePaymentRequired, getRequiredAmount, // amount from v1 or v2 requirements isV1Requirements, isV1Payload, isV2Payload, formatHBD, parseHBD,} from "@hiveio/x402/types";
formatHBD(0.05); // "0.050 HBD"parseHBD("0.050 HBD"); // 0.05Why HBD?
Section titled “Why HBD?”- No transaction fees. Every micropayment arrives in full.
- Stable value. HBD is designed to track 1 USD, with no wrapping or bridging.
- 3-second blocks. Fast enough for pay-per-request APIs.
- Readable accounts. Payers and recipients are Hive account names, not hex addresses.
- No gas estimation. A transfer is a transfer, so costs are predictable.