KYAPay
The KYAPay rail. Accept Skyfire pay and kya-pay tokens — funded holds charged per request until they are used up or expire.
npm install tollstile @tollstile/kyapayimport { 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
| Option | Default | Meaning |
|---|---|---|
environment | required | "production" or "sandbox". Selects the issuer, the API, and the env claim tokens must carry. |
sellerId | required | Your seller agent id. Tokens must name it in aud. |
serviceId | required | Your seller service id. Tokens must name it in tsi (or the older ssi). |
apiKey | required | Seller 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. |
issuers | Skyfire's issuer for environment | Trusted issuer origins, checked before any key fetch. JWKS is read from <issuer>/.well-known/jwks.json. |
apiUrl | Skyfire's API for environment | Seller API origin. |
clockSkewSeconds | 30 | Clock tolerance, 5–60 seconds. Also used when comparing Skyfire charge timestamps. |
verifyRequestSignature | none | RFC 9421 check for sender-constrained tokens (cnf). Without it, those tokens are refused. |
fetch, clock | globals | For tests. |
Capabilities
| Capability | Value | Why |
|---|---|---|
flows | authorization | Skyfire's documented flow is verify, deliver, charge; a token's funds are committed when it is minted. |
authorization | reusable | A token is charged many times until exhausted. limit = amt, expiresAt = exp, proof id = iss + jti, payer = <issuer>#<sub>. |
variableAmount | true | A charge may be any amount up to the remaining balance, so upTo() prices work. |
quotes | false | Buyers 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 · partialRefund | false | Skyfire documents no refund, void, or reversal. Not charging is the only release. |
lookup | true | GET /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:
- Read
KYAPay-Token(comma-separated or repeated). Members are classified bytyp;kyatokens are ignored. No payment token isabsent; more than one ismultiple_payment_tokens. algmust beES256;critis refused;kidis required; the token type must be accepted.issmust be on the allow list (untrusted_issuer, nothing fetched).- ES256 signature against the issuer's JWK. JWKS is cached 60 minutes; an unknown
kidrefetches at most once a minute. aud=sellerId,env=environment,tsi/ssi=serviceId,subpresent,jtia UUID,exp/iat/nbfwithinclockSkewSeconds, lifetime at most 24 hours.- Payment claims:
cur=USD,amt> 0 with at most 6 decimals. Card-settled tokens are refused, so card credentials never reach the ledger. cnfpresent:verifyRequestSignaturemust pass.amtmust 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 shows | lookup | settle before charging |
|---|---|---|
| only "charged" possible | settled | returns settled without charging |
| only "absent" possible | none | charges |
| both possible (same-amount charges in flight) | stays unknown | charges |
| less than the ledger recorded (list lagging) | stays unknown | charges |
| neither possible (something else charged the token) | stays unknown | stays unknown |
HTTP 404 | stays unknown | charges |
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()witholderThanMswell above Skyfire's list delay (the default 15 minutes). - Two or more
unknowncharges with overlapping amounts on one token can stay unknown permanently. They are reported throughonEventon 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:
- Whether charges are listed immediately after
POST /tokens/chargereturns, and the maximum delay if not. - Whether the charge list returns
404or an empty list for a token with no charges. - Whether any
4xxfrom the charge endpoint can accompany an applied charge. - Whether
chargedAtis Skyfire's server time, and its precision. - That the listed
valueis exactly the submitted amount. - Which of
env,tsiorssi, andsti.verifiedproduction 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.
L402
The L402 (Lightning) rail. The payer pays an invoice once, then presents the macaroon and preimage on every call until the credential's value or lifetime runs out.
Build a rail
Add any payment protocol or provider to Tollstile with createRail(), a fake provider, and the conformance kit — no core changes.