periplo
v0.1.0
Published
Client library for x402 payments on Stellar, focused on upto: sign a spending ceiling once, settle only the real usage.
Readme
periplo
Client library for x402 payments on Stellar. Pay only for what was actually used, on Stellar testnet, with a facilitator handling verification and settlement.
Why upto
Most x402 payments are exact: the buyer signs a fixed amount, the seller
charges exactly that. That works for a flat-rate API call, but not for
anything priced by real, variable usage: tokens generated by an LLM call,
seconds of access to a stream, rows returned by a query. Charging a flat
rate for those either overcharges light users or undercharges heavy ones.
upto fixes this: the buyer signs a spending ceiling once, before
usage is known. The seller measures what was actually used, and the
facilitator settles only that amount against the ceiling, on-chain, in the
same settlement transaction that would have moved the full ceiling. The
buyer never has to trust the seller's meter: the Soroban contract that
settles the payment enforces the ceiling itself and never holds funds
between settlements.
Example: an API that generates tokens at 0.005 USDC/token. A buyer signs a ceiling of 0.1 USDC (up to 20 tokens). The service generates 7 tokens and stops. The facilitator settles 0.035 USDC, the buyer is never charged for the other 13 tokens' worth of ceiling, and the contract's balance is zero both before and after.
Install
npm install periploQuickstart: buyer
import { createUptoStellarPayer, payAndFetch, selectUptoStellarRequirement } from "periplo";
const payer = createUptoStellarPayer(process.env.BUYER_SECRET_KEY!, "stellar:testnet", {
facilitatorBaseUrl: FACILITATOR_BASE_URL, // see note below the quickstarts
// Hard local cap: reject any signed amount above this, before signing anything.
maxSpendPerAuthorization: 1_000_000n, // 0.1 USDC/PTEST at 7 decimals
});
const result = await payAndFetch("https://example.com/paid-resource", payer, {
// Required for upto: payAndFetch defaults to the `exact` scheme.
selectRequirement: selectUptoStellarRequirement,
});
console.log(result.body); // the resource's response
console.log(result.settlement); // { transaction, amount, ... } once settledpayAndFetch does the whole 402 -> sign ceiling -> retry loop: it requests
the resource, reads its 402 challenge, has payer sign the ceiling,
retries with the signed payload attached, and returns the settled result.
Quickstart: seller
import { buildUptoRequirements, settleUptoUsage } from "periplo";
// One ceiling requirement, advertised in every 402 challenge for this route.
const ceiling = buildUptoRequirements({
network: "stellar:testnet",
payTo: SELLER_STELLAR_ADDRESS,
asset: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", // testnet USDC
settlementContract: "CA7OYVXWPSQHXNBWJQZ7TCKQILAVBHWDAPNRJTKY66LHR5K5LFXRV6TW",
maxAmount: 1_000_000n, // 0.1 USDC ceiling
});
// Respond 402 with { accepts: [ceiling] } when unpaid, per the x402 wire
// protocol (@x402/core/http's own header helpers).
// Once the buyer's signed payload arrives, do the metered work, then
// settle for what was actually used (must be <= the signed ceiling):
const tokensGenerated = 7;
const pricePerToken = 50_000n; // 0.005 USDC
const actualAmount = pricePerToken * BigInt(tokensGenerated);
const settlement = await settleUptoUsage(paymentPayload, ceiling, actualAmount, {
facilitatorBaseUrl: FACILITATOR_BASE_URL, // see note below the quickstarts
});
console.log(settlement.transaction); // the real Stellar testnet transaction hashFACILITATOR_BASE_URL must point at a facilitator with upto configured
for stellar:testnet (see Status and limitations: the public
https://periplo-testnet.fly.dev does not have it configured today).
Both quickstarts above are exercised for real against stellar:testnet as
part of this package's own release process (see Evidence below): they
are not illustrative pseudocode.
How it works
- The buyer signs a Soroban authorization tying together the asset, the
recipient, and the ceiling (
max_amount). The signature covers that tuple only, never the eventual real amount, so it stays valid even though the real amount isn't known yet. - The facilitator settles the real amount, which must be less than or
equal to the signed ceiling. The
UptoSettlementcontract enforces this on-chain: it can never move more than the buyer authorized. - Each authorization carries a single-use nonce, so a signed ceiling can settle exactly once.
- The contract never holds funds between settlements: it pulls the ceiling, pays the seller the real amount, and refunds the difference to the buyer, all inside the one settlement transaction. Its balance is zero before and after every call, which you can confirm yourself against Horizon.
Networks and assets
Only stellar:testnet is supported today. Any other network, including
Stellar pubnet/mainnet, throws UptoMainnetNotSupportedError explicitly:
this project has no mainnet fee-sponsor key, so nothing about upto
mainnet support is implied or half-built.
UptoSettlementcontract:CA7OYVXWPSQHXNBWJQZ7TCKQILAVBHWDAPNRJTKY66LHR5K5LFXRV6TW- Testnet USDC (SAC):
CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA - PTEST, a self-issued testnet asset with no value (SAC):
CCK2UCUDA2CYGBHIPURM6TIXZEHULBVIGPVB2UTP3R2LCIKB3O5P723X
API reference
Buyer, exact (existing scheme)
createExactStellarPayer(secretKey: string, network: Network): PaymentPayerSigns and pays a fixedexactamount.
Buyer, upto
createUptoStellarPayer(secretKey: string, network: Network, options: CreateUptoStellarPayerOptions): PaymentPayerSigns anuptospending ceiling.options:facilitatorBaseUrl: stringmaxSpendPerAuthorization: bigintrpcUrl?: stringdeadlineLedgerOffset?: number(ledgers the signed ceiling stays valid for; default30)facilitatorClient?: Pick<HTTPFacilitatorClient, "getSupported">(for tests)
selectUptoStellarRequirement(accepts: readonly PaymentRequirements[], network: string): PaymentRequirements | undefinedPure selector: the firstupto/contract-profile option fornetwork.
Buyer, scheme-agnostic
payAndFetch(resourceUrl: string, payer: PaymentPayer, options?: PayAndFetchOptions): Promise<BuyerFetchResult>options.selectRequirementdefaults toselectExactStellarRequirement; passselectUptoStellarRequirementto pay anuptochallenge.discoverPayAndFetch(facilitatorBaseUrl: string, query: string, payer: PaymentPayer, options?: PayAndFetchOptions): Promise<BuyerFetchResult & { resource: DiscoveryResource }>SameselectRequirementoption, applied to Bazaar discovery.searchBazaar,selectPayableResource,resolveResourceRequestUrl,selectExactStellarRequirement: lower-level piecespayAndFetch/discoverPayAndFetchare built from.PaymentPayer(interface):{ network: string; createPaymentPayload(paymentRequired): Promise<PaymentPayload> }. Implement this yourself to plug in a different signer.
Seller, upto
buildUptoRequirements(config: UptoRequirementConfig): PaymentRequirementsBuilds the ceiling requirement to advertise.config:network,payTo,asset,settlementContract,maxAmount: bigint,maxTimeoutSeconds?,resource?.verifyUptoPayment(paymentPayload, ceilingRequirements, options: UptoFacilitatorOptions): Promise<VerifyResponse>settleUptoUsage(paymentPayload, ceilingRequirements, actualAmount: bigint, options: UptoFacilitatorOptions): Promise<SettleResponse>Rejects locally (no facilitator call) ifactualAmountis negative or exceeds the signed ceiling.UptoFacilitatorOptions:{ facilitatorBaseUrl: string; facilitatorClient?: Pick<HTTPFacilitatorClient, "verify" | "settle"> }.
Seller, discovery metadata (scheme-agnostic)
definePaidResource(config: PaidResourceConfig): PaidResourceDeclarationDeclares Bazaar discovery metadata and a matching query-parameter parser from one spec, so they can't drift apart.
Error codes
Every rejection this library throws carries a stable code (in addition
to .name and a non-empty .message), so callers can branch on it
without parsing message text.
| code | class | thrown when |
| --- | --- | --- |
| upto_mainnet_not_supported | UptoMainnetNotSupportedError | createUptoStellarPayer called for any network other than stellar:testnet |
| upto_no_matching_requirement | NoUptoPaymentOptionError | the 402 challenge has no upto/contract option for this network, or it's missing extra.settlementContract |
| upto_spending_ceiling_exceeded | SpendingCeilingExceededError | the offered ceiling exceeds the configured maxSpendPerAuthorization, checked before any signing |
| upto_facilitator_signer_unavailable | UptoFacilitatorSignerUnavailableError | the facilitator's GET /supported lists no signer for this network |
| upto_simulation_failed | UptoSimulationError | simulating the ceiling-signing transaction failed |
| upto_settlement_exceeds_ceiling | UptoSettlementExceedsCeilingError | settleUptoUsage called with a real amount above the ceiling, or negative |
| upto_settlement_failed | UptoSettlementFailedError | the facilitator rejected or failed the settlement; .errorReason carries its own reason |
| no_acceptable_payment_option | NoAcceptablePaymentOptionError | the 402 challenge has no option matching selectRequirement |
| no_discoverable_resource | NoDiscoverableResourceError | Bazaar search returned nothing payable |
| payment_failed | PaymentFailedError | the paid retry itself was rejected; .status carries the HTTP status |
| unexpected_response | UnexpectedResponseError | any other non-2xx/402 response; .status carries the HTTP status |
Security
- Non-custodial by construction. The
UptoSettlementcontract's balance is zero before and after every settlement; it never holds funds between calls. Verify this yourself against Horizon for any settlement hash below. - The ceiling is checked before signing, not after.
maxSpendPerAuthorizationis enforced locally, in this library, before any Stellar RPC call or signature is produced. - Secrets never leave the process.
Keypair.fromSecretholds the secret in memory; only signed output (a signature, a signed transaction) is ever returned. - Report a vulnerability: github.com/Eras256/Periplo/security.
Status and limitations
- The
uptowire format follows the draft spec proposed in x402-foundation/x402#3098. That PR is open, not merged, checked against the GitHub API at the time of each release; this library implements a draft, not an approved standard. stellar:testnetonly. No mainnet support, by design (see Networks and assets).- No external security audit has been performed on this library or on the
UptoSettlementcontract it settles through. - The publicly deployed facilitator (
https://periplo-testnet.fly.dev) advertisesexactonly as of this release; it does not yet haveuptoconfigured, checked live against its ownGET /supportedat the time of writing. The quickstarts above work against any facilitator runningapps/facilitator's own code withuptoconfigured (see this repo'sdocs/TOOLING.md); the evidence transaction above was produced against such an instance, not the public URL. - Unit tests cover the deterministic logic (ceiling checks, requirement selection/construction, every error code) without needing network access. The signing and settlement path itself is proven by running the quickstarts above for real against testnet before each release (see Evidence), not by a mocked integration test.
Evidence
Real stellar:testnet settlement transactions, produced by running this
package's own quickstarts from a packed tarball, outside the monorepo:
- 2026-09-27, v0.1.0:
8720f1d22fa7e00570b392963cfaf4fc6cb8f0f0c0ce5fa19095c4d6902432ab, testnet USDC: 0.10 USDC ceiling signed, 0.035 USDC (7 measured "tokens" at 0.005 USDC each) actually settled.successful: trueon Horizon; the contract's USDC balance was0immediately after.
License
Apache-2.0
