npm packages are not installable yet: [email protected] can be published after 2026-09-16 10:30 UTC. Details
Tollstile

KYAPay

The KYAPay rail. Accept Skyfire pay and kya-pay tokens — funded holds charged per request until they are used up or expire.

Public Beta · early access
npm install tollstile @tollstile/kyapay
import { createTollstile } from "tollstile";
import { tollstile } from "@tollstile/hono";
import { kyapay } from "@tollstile/kyapay";
import { postgresLedger } from "@tollstile/postgres";

const toll = createTollstile({
  rails: [
    kyapay({
      environment: "sandbox",
      sellerId: process.env.SKYFIRE_SELLER_ID,
      serviceId: process.env.SKYFIRE_SERVICE_ID,
      apiKey: process.env.SKYFIRE_API_KEY,
    }),
  ],
  ledger: postgresLedger({ query, transaction }),
  secret: process.env.TOLLSTILE_SECRET,
});

app.get("/report", tollstile(toll.price("$0.01")), (c) => c.json({ ok: true }));

// Resolves charges whose outcome Skyfire left unknown.
setInterval(() => void toll.reconcile(), 5 * 60_000);

A KYAPay payment token is a funded hold the buyer mints with Skyfire. The rail verifies the token on every request, runs your handler on a reservation, then charges the delivered amount against the token with Skyfire's seller API. Buyers send it in the KYAPay-Token header, or _meta["kyapay/token"] over MCP.

Options

OptionDefaultMeaning
environmentrequired"production" or "sandbox". Selects the issuer, the API, and the env claim tokens must carry.
sellerIdrequiredYour seller agent id. Tokens must name it in aud.
serviceIdrequiredYour seller service id. Tokens must name it in tsi (or the older ssi).
apiKeyrequiredSeller agent API key, sent as skyfire-api-key to charge and list charges. Never logged or stored.
tokenTypes["pay", "kya-pay"]Accepted token types. ["pay"] keeps buyer identity claims out of your ledger.
issuersSkyfire's issuer for environmentTrusted issuer origins, checked before any key fetch. JWKS is read from <issuer>/.well-known/jwks.json.
apiUrlSkyfire's API for environmentSeller API origin.
clockSkewSeconds30Clock tolerance, 5–60 seconds. Also used when comparing Skyfire charge timestamps.
verifyRequestSignaturenoneRFC 9421 check for sender-constrained tokens (cnf). Without it, those tokens are refused.
fetch, clockglobalsFor tests.

Capabilities

CapabilityValueWhy
flowsauthorizationSkyfire's documented flow is verify, deliver, charge; a token's funds are committed when it is minted.
authorizationreusableA token is charged many times until exhausted. limit = amt, expiresAt = exp, proof id = iss + jti, payer = <issuer>#<sub>.
variableAmounttrueA charge may be any amount up to the remaining balance, so upTo() prices work.
quotesfalseBuyers mint tokens with Skyfire; nothing the server sends comes back inside the token. Fixed prices only: this rail is excluded from computed-price routes. A route fails to compile only if no configured rail can serve it. Static upTo() prices are supported.
refund · partialRefundfalseSkyfire documents no refund, void, or reversal. Not charging is the only release.
lookuptrueGET /api/v1/tokens/{jti}/charges, with the accounting proof below.

livemode is true in both environments.

Verification

In order, with no network call until the issuer is trusted:

  1. Read KYAPay-Token (comma-separated or repeated). Members are classified by typ; kya tokens are ignored. No payment token is absent; more than one is multiple_payment_tokens.
  2. alg must be ES256; crit is refused; kid is required; the token type must be accepted.
  3. iss must be on the allow list (untrusted_issuer, nothing fetched).
  4. ES256 signature against the issuer's JWK. JWKS is cached 60 minutes; an unknown kid refetches at most once a minute.
  5. aud = sellerId, env = environment, tsi/ssi = serviceId, sub present, jti a UUID, exp/iat/nbf within clockSkewSeconds, lifetime at most 24 hours.
  6. Payment claims: cur = USD, amt > 0 with at most 6 decimals. Card-settled tokens are refused, so card credentials never reach the ledger.
  7. cnf present: verifyRequestSignature must pass.
  8. amt must cover the route price.

Settlement and lookup

Skyfire's charge API accepts no idempotency key and returns no charge id, so a Tollstile charge can never be matched to a Skyfire charge directly. The rail reasons from the ledger's own accounting for the token:

excess = Skyfire's listed total − ledger consumed      (charges recorded as settled)
others = ledger reserved − this charge's reservation   (the most other in-flight charges could add)

"charged" is possible  ⇔  0 ≤ excess − amount ≤ others, and a listed charge is not older than this charge (less clock skew)
"absent"  is possible  ⇔  0 ≤ excess ≤ others
The charge list showslookupsettle before charging
only "charged" possiblesettledreturns settled without charging
only "absent" possiblenonecharges
both possible (same-amount charges in flight)stays unknowncharges
less than the ledger recorded (list lagging)stays unknowncharges
neither possible (something else charged the token)stays unknownstays unknown
HTTP 404stays unknowncharges

Charge responses: 200 with amountCharged equal to the requested amount is settled. A 4xx with a documented Skyfire error code is rejected: the output is withheld and the client gets a fresh 402 with error code settlement_rejected. Anything else — 5xx, non-JSON, an unknown code, a different amount, a timeout — is unknown, and reconciliation resolves it. The settlement reference is <jti>:<charge id>.

Residual risks:

  • The proofs assume your ledger is the only party charging these tokens with your API key.
  • They assume the charge list shows every accepted charge by the time it is read. Run reconcile() with olderThanMs well above Skyfire's list delay (the default 15 minutes).
  • Two or more unknown charges with overlapping amounts on one token can stay unknown permanently. They are reported through onEvent on every reconcile and must be resolved by hand against the Skyfire dashboard.
  • Skyfire accepts charges for 24 hours after exp. A charge still unresolved after that is rejected by Skyfire.

Stored data

The authorization's data is { token, tokenId }. The compact JWT is stored because Skyfire charges only against the full signed token, and the charge happens after the handler, possibly in another process. It can be charged only by the seller in aud, with that seller's API key, which is never stored. kya-pay tokens carry buyer identity claims; set tokenTypes: ["pay"] to keep them out of the ledger. The rail does not implement redact: a reusable token must stay chargeable until it is exhausted or expires.

402 challenge

KYAPay defines no challenge format. accepts[].details names the KYAPay-Token header, the accepted token types, the issuer, where to create tokens, and a message, and includes the A2A extension's kyapay.payment.required shape. MCP challenges use { style: "tollstile", … } with the same fields. Receipts: the kyapay-receipt header, or _meta["kyapay/receipt"], as { success, amount_charged, token_id }.

Skyfire recommends 403 for a missing token and 401 for an invalid one; Tollstile answers 402 for both, with a stable error.code and the rail-specific reason in error.detail.

Verification status

Tested only against fakes. Nothing has been run against Skyfire. Tests use ES256 keys generated in the test, a fake JWKS endpoint, and a fake Skyfire API built from the documented request and response shapes. The settlement and lookup logic is marked experimental in the package until Skyfire confirms:

  1. Whether charges are listed immediately after POST /tokens/charge returns, and the maximum delay if not.
  2. Whether the charge list returns 404 or an empty list for a token with no charges.
  3. Whether any 4xx from the charge endpoint can accompany an applied charge.
  4. Whether chargedAt is Skyfire's server time, and its precision.
  5. That the listed value is exactly the submitted amount.
  6. Which of env, tsi or ssi, and sti.verified production tokens carry.

To verify in sandbox: create a seller agent and service, mint a pay token with a buyer agent, and call a route priced below the token amount. Check that each request produces exactly one charge, that cutting the network during a charge leaves it unknown and reconcile() resolves it without a second charge, and that a request after the token is exhausted is refused.

Retries

Send a client Idempotency-Key (or MCP key). KYAPay supplies no protocol key. Without one, each admitted request using the reusable token creates a new charge. The payer scope is <issuer>#<sub>, so the same subject under different issuers has separate keys; a fresh token from the same issuer and subject can find the same keyed charge.

Completed payments return 409 already_paid, running attempts 409 request_in_progress, and ambiguous outcomes 503 payment_outcome_unknown. The charge-list ambiguity described above still applies; an idempotency key does not make an unknown provider outcome known. See Idempotency.

On this page