Check before you send
A relay refused for lack of entitlement costs only a round trip: no signature is spent and no Relay Units are reserved. Checking first lets you decide what to do before you ask for a signature.
Read the quota and a quote
/**
* Before spending a signature: will this transaction be served, and what will it cost?
*
* Read the quota and ask for a quote. Both routes are public, so you need no key:
*
* GET /v1/account/{erd}/quota the account's service state, cap, escrow and period
* POST /v1/quote this transaction's Relay Units, and what they would be billed to
*
* `billedAs` says how the server would bill this transaction: `grant`, `bonus`, `cap`, `payg` or
* `free` mean it would be served, and `none` means it would not. The quota's `state` says why. The
* types come from `@corelayer/sdk`.
*/
import type { CoRelayerClient, components } from '@corelayer/sdk';
import type { UnsignedTransaction } from './wallet.ts';
type Quota = components['schemas']['Quota'];
type RelayQuote = components['schemas']['RelayQuote'];
export type Verdict =
| {
readonly ok: true;
readonly ru: bigint;
readonly billedAs: NonNullable<RelayQuote['billedAs']>;
/** Non-zero only when the units would be billed as pay-as-you-go. */
readonly priceMicroUsdc: bigint;
}
| {
readonly ok: false;
readonly ru: bigint;
readonly reason:
| 'buy-a-plan' // NO_ACCOUNT, LAPSED
| 'cap-reached' // HALTED_CAP: turn on pay-as-you-go, or move up a tier
| 'top-up-escrow' // HALTED_PAYG_EMPTY
| 'suspended' // SUSPENDED: talk to support, buying does not help
| 'not-enough-left'; // served, but not for a transaction this heavy
readonly state: Quota['state'];
};
/** Pure: the decision, from the two documents. */
export function decide(quota: Quota, quote: RelayQuote): Verdict {
const ru = BigInt(quote.ru);
if (quote.billedAs !== undefined && quote.billedAs !== 'none') {
return {
ok: true,
ru,
billedAs: quote.billedAs,
priceMicroUsdc: BigInt(quote.priceMicroUsdc),
};
}
return { ok: false, ru, reason: reasonOf(quota.state), state: quota.state };
}
/** Why an account is not served, from its quota's `state`. */
export function reasonOf(state: Quota['state']): Extract<Verdict, { ok: false }>['reason'] {
switch (state) {
case 'NO_ACCOUNT':
case 'LAPSED':
return 'buy-a-plan';
case 'HALTED_CAP':
return 'cap-reached';
case 'HALTED_PAYG_EMPTY':
return 'top-up-escrow';
case 'SUSPENDED':
return 'suspended';
case 'ACTIVE_CAP':
case 'ACTIVE_PAYG':
case 'RENEWAL_PENDING':
return 'not-enough-left';
}
}
/** Reads both documents and decides. `account` defaults to the sender. */
export async function preflight(
client: CoRelayerClient,
tx: UnsignedTransaction,
account: string = tx.sender,
signal?: AbortSignal,
): Promise<Verdict> {
const [{ data: quota }, { data: quote }] = await Promise.all([
client.getQuota(account, signal),
client.quoteRelay({ tx, account }, signal),
]);
return decide(quota, quote);
}
An agent can check the account that pays for it without holding that account's key. The quota comes from public chain data, and the quote reads only the fields of the transaction that affect its price.
| From | Member | Meaning |
|---|---|---|
| Quota | state | ACTIVE_CAP, ACTIVE_PAYG, RENEWAL_PENDING are served; HALTED_CAP, HALTED_PAYG_EMPTY, LAPSED, NO_ACCOUNT, SUSPENDED are not. |
| Quota | cap.left, payg.leftRu, period.endMs | What remains in this period, and when the period ends, in chain time. |
| Quota | chainTimeMs | The block timestamp every one of those is measured against. |
| Quote | ru | Relay Units this transaction costs, from the formula. |
| Quote | billedAs | grant, bonus, cap, payg or free when it would be served, none when it would not. |
| Quote | priceMicroUsdc | Non-zero only when it would be billed as pay-as-you-go. |
A period ends by chain time. Compare period.endMs with the quota's chainTimeMs, and ignore your
own clock.
In sponsor mode
If your server pays for its users with a sponsor key, the check above answers the wrong question.
POST /v1/quote and POST /v1/validate don't read X-Api-Key, so they price the transaction for
its sender:
- A user you sponsor has no plan, so
billedAscomes backnoneandpreflightsaysbuy-a-plan, although your plan would pay. - Naming your own account in
accountdoesn't help. Your account hasn't listed that user, so the quote can't bill it, and the verdict saysnot-enough-left.
The relay itself, sent with your key, bills your account: its answer carries
billing.authMode: "api_key". So in sponsor mode:
- Read your own quota, the account that owns the key, not the sender's.
- Take only
rufrom the quote. It doesn't depend on who pays. IgnorebilledAsandpriceMicroUsdc, andwouldBillfrom/v1/validate. - Don't run the check on every action.
/v1/quoteand/v1/validateallow 2 requests per second per IP (Limits), so a busy server that checks each user's action hits that limit long before its plan runs out. Read your quota once in a while, or keep it current from the notice feed, and pass it in. The Relay Unit cost can be worked out on your side with the formula, without a request.
Add this to the same file:
/**
* In sponsor mode, `POST /v1/quote` cannot see your sponsor key. It prices the transaction for the
* sender, and a sponsored user has no plan, so `billedAs` is `none` and says nothing about your
* account. Naming your own account does not help either: it has not listed the user, so the quote
* cannot bill it. Take only `ru` from the quote and judge your own account's quota, with the rule
* the relay applies to the account a sponsor key bills: grant first, then the cap, then
* pay-as-you-go while it has escrow.
*
* Pure, so a quota you read a minute ago serves every action until then.
*/
export function decideSponsored(yourQuota: Quota, ru: bigint): Verdict {
const amount = (value: number | string | undefined): bigint => BigInt(value ?? 0);
const { state, grant, cap, payg } = yourQuota;
if (state === 'ACTIVE_CAP' || state === 'ACTIVE_PAYG' || state === 'RENEWAL_PENDING') {
if (amount(grant?.left) >= ru) return { ok: true, ru, billedAs: 'grant', priceMicroUsdc: 0n };
if (state !== 'ACTIVE_PAYG' && amount(cap?.left) >= ru) {
return { ok: true, ru, billedAs: 'cap', priceMicroUsdc: 0n };
}
if (payg?.enabled === true && amount(payg.escrowMicro) > 0n) {
return { ok: true, ru, billedAs: 'payg', priceMicroUsdc: amount(payg.pricePerRuMicro) * ru };
}
}
return { ok: false, ru, reason: reasonOf(state), state };
}
/**
* The same check for a server that relays with a sponsor key: your quota, and the quote's `ru`.
* `yourAccount` is the account that owns the key. Pass `yourQuota` when you already hold a recent
* one, so the check costs one request instead of two.
*/
export async function preflightSponsored(
client: CoRelayerClient,
tx: UnsignedTransaction,
yourAccount: string,
options: { readonly yourQuota?: Quota; readonly signal?: AbortSignal } = {},
): Promise<Verdict> {
const { yourQuota, signal } = options;
const [quota, { data: quote }] = await Promise.all([
yourQuota ?? client.getQuota(yourAccount, signal).then((r) => r.data),
// No `account`: the quote is asked only for `ru`, which does not depend on who pays.
client.quoteRelay({ tx }, signal),
]);
return decideSponsored(quota, BigInt(quote.ru));
}
A sponsor key also has daily limits of its own (per key, and per user if you set one). The quota
doesn't show them. When one is reached, the relay answers
QUOTA_EXHAUSTED with details.scope. The whole sponsor flow is in
Pay for your users.
Choosing a tier
/**
* Choosing a tier from the live pricing document.
*
* `GET /v1/pricing?audience=agent` is public. Every amount in it is a decimal string in micro-USDC
* (the payment token has 6 decimals). Compare amounts as BigInt, because floats lose precision. Do
* not multiply a `price` by a million: it is already in micro-USDC.
*/
import type { CoRelayerClient, components } from '@corelayer/sdk';
type PricingTier = components['schemas']['PricingTier'];
/**
* The largest purchasable plan whose monthly price fits the budget; the metered tier when none
* does. `undefined` only when nothing at all is on sale.
*/
export function pickTier(
tiers: readonly PricingTier[],
monthlyBudgetMicroUsdc: bigint,
): PricingTier | undefined {
const onSale = tiers.filter((tier) => tier.status === 'active' && tier.available !== false);
const metered = onSale.find((tier) => tier.periodMs === 0);
const plans = onSale
.filter((tier) => tier.periodMs > 0 && BigInt(tier.price) <= monthlyBudgetMicroUsdc)
.sort((a, b) =>
BigInt(b.capRu) > BigInt(a.capRu) ? 1 : BigInt(b.capRu) < BigInt(a.capRu) ? -1 : 0,
);
return plans[0] ?? metered;
}
export async function pickAgentTier(
client: CoRelayerClient,
monthlyBudgetMicroUsdc: bigint,
): Promise<PricingTier | undefined> {
const { data } = await client.getPricing({ audience: 'agent' });
return pickTier(data.tiers, monthlyBudgetMicroUsdc);
}
Every amount in the pricing document is a decimal string in micro-USDC. A tier that is listed but
not on sale carries available: false and unavailableReason: "CAPACITY".
(Tiers · Tiers for agents)
Do not poll the cap
Instead of polling the quota, read the notice feed or follow the account stream. Both carry the same notices.
The notice feed
GET /v1/account/{erd}/notices?sinceSeq=<last seen> returns the account's notices in order. It
needs native auth or a sponsor key with the read scope. seq is per account and strictly
increasing, so the last seq you processed is a reliable checkpoint. The response is a
page: { items, nextCursor, hasMore }, each item a Notice with kind, severity, title,
message, data and seq.
The kinds that concern the cap and the price:
kind | When |
|---|---|
cap.threshold | Usage crossed 80 % of the cap, and each threshold you configured. |
cap.reached | 100 %. |
payg.escrow_low, payg.escrow_empty | Pay-as-you-go escrow below its low mark; exhausted. |
service.halted, service.resumed | The account stopped being served, or started again. |
tariff.scheduled | A price increase was scheduled, 48 hours ahead. Cannot be switched off. |
renewal.upcoming, renewal.succeeded, renewal.skipped | Auto-renew, 72 hours before and after. |
The account stream
For a connection held open, GET /v1/stream streams notices, intent changes and quota movements
for one account. Send a native-auth header, or from a browser use a single-use ticket from
POST /v1/stream/tickets. A browser's EventSource cannot send headers, and a token must never
travel in a URL.
This deployment does not deliver webhooks or e-mail: registering a webhook endpoint answers 503 with reason: WEBHOOK_DELIVERY_NOT_AVAILABLE, and no e-mail is sent. The notice feed and the stream carry every notice.
When the cap is reached
QUOTA_EXHAUSTED is a 429 without a Retry-After header, because waiting does not help until the
period ends or the account adds funds. Do not retry it in a loop. Its details carry reason
(CAP_REACHED_PAYG_OFF or PAYG_ESCROW_EMPTY), periodEndMs, pricingUrl and x402Url.
Which errors to wait on
| Code | Status | Waiting helps? | Do |
|---|---|---|---|
RATE_LIMITED | 429 | Yes, after Retry-After (also in details.retryAfterMs) | Wait, then send the same bytes again. |
QUOTA_EXHAUSTED | 429 | No | Turn on pay-as-you-go, top up its escrow, or move up a tier. |
NO_ENTITLEMENT | 402 | No | Buy or renew a plan. |
PAYG_PRICE_ABOVE_MAX | 409 | Only if the price comes back down | Raise your max_payg_price, or wait. |
RATE_LIMITED and QUOTA_EXHAUSTED are both 429s but need different actions, so branch on code.