Skip to content

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.

The protocol uses HTTP status 402 Payment Required to negotiate a payment between a client and a server:

  1. The client requests a protected resource.
  2. The server answers 402 with payment requirements (amount, recipient, network). They are in the JSON body and, base64 encoded, in the x-payment response header.
  3. The client builds and signs an HBD transfer, but does not broadcast it.
  4. The client retries the request with the signed transaction in the x-payment request header.
  5. The server middleware sends the payment to a facilitator, first to /verify, then to /settle.
  6. The facilitator checks the signature against the payer’s on-chain active key, broadcasts the transfer and marks the nonce as spent.
  7. The server delivers the content and adds an x-payment-response header with the settlement result.
  1. Client
  2. Server
  3. Facilitator
  1. Client to Server: GET /premium
  2. Server to Client: 402 + payment requirements
  3. Client: Sign an HBD transfer, don't broadcast
  4. Client to Server: GET /premium with x‑payment
  5. Server to Facilitator: POST /verify
  6. Facilitator to Server: isValid: true
  7. Server to Facilitator: POST /settle
  8. Facilitator: Broadcast, mark nonce spent
  9. Facilitator to Server: success, txId
  10. Server to Client: 200 + content, x‑payment‑response
One paid request: the client signs an HBD transfer, the server has the facilitator verify and settle it, then serves the content.
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
Terminal window
npm install @hiveio/x402
# or
pnpm add @hiveio/x402

Dependencies. 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 helpers
import { ... } from "@hiveio/x402/client"; // HiveX402Client, signing helpers
import { ... } 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 stores

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
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 });
}
);
app/api/premium/route.ts
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 });
}
);

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.

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);
}

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 402
const 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.

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: parseRequirements finds nothing (hence the body fallback above) and you can’t read the settlement result from x-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 without requestSignTx cannot 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.

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.

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)
Terminal window
curl https://x402.ecency.com/supported-networks
# {"networks":["hive:mainnet"]}

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.

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 clients
const 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.

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.

// v1
interface 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 responses
interface 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;
}
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.05
  • 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.