Skip to main content
The facilitator is the operator of a Flex deployment. It accepts client-signed authorizations from middleware, validates them, holds the reserved capacity in memory, and submits + finalizes pending settlements on-chain. This page describes the operator-side primitives. The full implementation lives in @faremeter/payment-solana/flex/facilitator and @faremeter/flex-solana/facilitator. For the protocol-level model, see Flex Concepts.

Two layers of API

LayerPackageUse it for
Plug-in@faremeter/payment-solana/flex/facilitatorA drop-in FacilitatorHandler that integrates with Faremeter middleware.
Building blocks@faremeter/flex-solana/facilitatorHold manager, escrow accounting, split merging — for custom integrations.
If you’re standing up a Faremeter facilitator service that already speaks x402, start with the plug-in. If you’re embedding Flex into a non-Faremeter system, use the building blocks directly.

The plug-in: createFacilitatorHandler

import { createSolanaRpc } from "@solana/kit";
import { createKeyPairSignerFromBytes } from "@solana/kit";
import { createFacilitatorHandler } from "@faremeter/payment-solana/flex/facilitator";
import { address } from "@solana/kit";

const rpc = createSolanaRpc(rpcURL);
const facilitatorSigner = await createKeyPairSignerFromBytes(facilitatorBytes);

const handler = await createFacilitatorHandler(
  "devnet",
  rpc,
  facilitatorSigner,
  {
    supportedMints: [address("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU")],
    defaultSplits: [
      { recipient: "<merchant-token-account>", bps: 9500 },
      { recipient: "<facilitator-fee-token-account>", bps: 500 },
    ],
    maxSubmitRetries: 30,
    submitRetryDelayMs: 1000,
    minGracePeriodSlots: 150n,
    flushIntervalMs: 5000,
  },
);
The returned FlexFacilitator is a FacilitatorHandler (so it slots into Faremeter’s facilitator service) plus three extras:
  • flush() — drain settled in-memory holds to chain immediately.
  • getHoldManager() — inspect the in-memory hold state (debugging, metrics).
  • stop() — cancel the background flush interval.
The handler runs a periodic flush in the background (flushIntervalMs), so most operators never need to call flush() themselves.

Verify and settle

The handler implements the standard Faremeter verify / settle lifecycle:
  1. verify — decodes the client’s FlexPaymentPayload, checks the Ed25519 signature, fetches escrow + session-key state, runs tryHold on the in-memory hold manager, and returns success if the reservation took. The hold initially reserves the full maxAmount; settle reduces it later. Plan escrow capacity against the ceiling, not the expected actual cost.
  2. settle — the middleware reports the actual amount consumed. The handler updates the hold’s settleAmount (must be <= the previously held maxAmount), marks it settled, and the next flush pushes it on-chain. The handler returns the x402 settle response immediately; on-chain confirmation is asynchronous.
  3. flush (background) — collects all settled holds, builds submit_authorization instructions in batches, signs them with the facilitator key, and tracks confirmation. Once submitted, holds become submitted. After the refund window closes, the handler builds finalize instructions to release funds.
PERMANENT_SUBMIT_ERRORS (expired authorizations, invalid signatures, expiry-too-far, etc.) are not retried — the hold is dropped and an error is logged. Transient errors retry up to maxSubmitRetries times with submitRetryDelayMs between attempts.

Middleware integration: createUptoHandler

For variable-amount endpoints (the canonical Flex use case), @faremeter/payment-solana/flex/hono exposes createUptoHandler. It takes an authorize callback that decides the maximum amount for a given request, then a handle callback that does the work and reports the actual amount via a settle function.
import { Hono } from "hono";
import { createUptoHandler } from "@faremeter/payment-solana/flex/hono";

const app = new Hono();

app.post(
  "/inference",
  createUptoHandler({
    facilitatorURL: "https://facilitator.example.com",
    accepts: [
      {
        scheme: "@faremeter/flex",
        network: "solana-devnet",
        amount: "100000", // ceiling per request
        asset: "USDC",
        payTo: "<merchant-token-account>",
      },
    ],
    authorize: async (body) => {
      const tokensRequested = (body as { maxTokens: number }).maxTokens;
      return BigInt(tokensRequested * 10); // 10 micro-USDC per token
    },
    handle: async (body, settle) => {
      const result = await runInference(body);
      const cost = BigInt(result.tokensUsed * 10); // actual cost
      await settle(cost);
      return new Response(JSON.stringify(result), {
        headers: { "content-type": "application/json" },
      });
    },
  }),
);
This pattern is the natural fit for AI inference, streaming responses, metered APIs, and any other workflow where the cost is only known after the work is done.

Building blocks: hold manager

Use createHoldManager directly if you’re building outside the Faremeter middleware abstraction.
import { createHoldManager } from "@faremeter/flex-solana/facilitator";

const holds = createHoldManager();

const result = holds.tryHold(
  {
    escrow,
    mint,
    settleAmount: 50_000n,
    maxAmount: 100_000n,
    authorizationId,
    expiresAtSlot,
    sessionKeyAddress,
    sessionKeyPDA,
    vault,
    splits,
    signatureBytes,
    message,
    payer,
    validUntilSlot,
  },
  vaultBalance, // fetched from chain
  onChainCommitted, // sum of pending settlements for this escrow+mint
  onChainPendingCount, // current pending_count from the escrow account
);

if (!result.ok) {
  // Insufficient capacity, pending limit reached, or duplicate auth ID.
  // Reject the request to the middleware.
}
A hold moves through five states managed by the manager:
held -> settled -> submitting -> submitted -> finalizing
StateMeaning
heldReserved in memory; merchant work has not yet completed.
settledActual amount known; ready to be flushed on-chain.
submittingA submit_authorization transaction is in flight.
submittedPending settlement exists on-chain; waiting for refund window to close.
finalizingA finalize transaction is in flight to release funds to recipients.
The manager enforces two invariants when accepting new holds: the sum of all unsubmitted hold amounts (per escrow + mint) must fit in the vault balance minus on-chain commitments, and the total pending count (in-memory + on-chain) must stay below MAX_PENDING_SETTLEMENTS = 16.

Building blocks: accounting

fetchEscrowAccounting is the snapshot you take from chain to feed the hold manager:
import { fetchEscrowAccounting } from "@faremeter/flex-solana/facilitator";

const snapshot = await fetchEscrowAccounting(rpc, escrowAddress, [
  mintA,
  mintB,
]);

snapshot.vaultBalances; // Map<mint, bigint>
snapshot.holds; // HoldEntry[] from on-chain pending settlements
snapshot.totalPendingByMint; // Map<mint, bigint>
snapshot.pendingCount; // bigint
snapshot.maxPending; // 16
snapshot.availableByMint; // vault - on-chain pending, per mint
snapshot.canSubmit; // false if at MAX_PENDING_SETTLEMENTS
Snapshots are cheap to take but should be cached briefly per escrow (the plug-in caches for snapshotMaxAgeMs, default 10 seconds) to avoid hammering RPC.

Building blocks: split merging

When the merchant runs the same recipient through multiple split entries (for example, a base fee + a referral kick-back going to the same operator), mergeSplits collapses duplicates:
import { mergeSplits } from "@faremeter/flex-solana/facilitator";

const merged = mergeSplits([
  { recipient: alice, bps: 6000 },
  { recipient: bob, bps: 2500 },
  { recipient: alice, bps: 1500 }, // alice again
]);
// -> [{ recipient: alice, bps: 7500 }, { recipient: bob, bps: 2500 }]
This matters because the on-chain program rejects splits with duplicate recipients (FLEX_ERROR__DUPLICATE_SPLIT_RECIPIENT).

Operational considerations

  • Activity tracking. last_activity_slot is only updated by submit_authorization and refund (both require facilitator signatures). finalize is permissionless — even cranking it forever doesn’t keep the deadman timer alive. Make sure your facilitator submits or refunds at least once per deadman_timeout_slots, even on quiet escrows.
  • Refund-window hygiene. Honor refund requests from middleware (failed deliveries, disputes) by issuing refund instructions during the window. Once the window closes, finalize is the only remaining lever.
  • Pending-count headroom. With the cap at 16, a busy escrow saturates fast. Submit and finalize aggressively so middleware verify calls don’t get rejected for capacity.
  • Session-key grace period. The facilitator should surface a documented submission SLA so clients can pick a revocation_grace_period_slots that’s at least 2x the SLA. The default minimum the plug-in enforces is 150 slots (~1 minute).

Further reading