Skip to main content

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​

preflight.ts
/**
* 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.

FromMemberMeaning
QuotastateACTIVE_CAP, ACTIVE_PAYG, RENEWAL_PENDING are served; HALTED_CAP, HALTED_PAYG_EMPTY, LAPSED, NO_ACCOUNT, SUSPENDED are not.
Quotacap.left, payg.leftRu, period.endMsWhat remains in this period, and when the period ends, in chain time.
QuotachainTimeMsThe block timestamp every one of those is measured against.
QuoteruRelay Units this transaction costs, from the formula.
QuotebilledAsgrant, bonus, cap, payg or free when it would be served, none when it would not.
QuotepriceMicroUsdcNon-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 billedAs comes back none and preflight says buy-a-plan, although your plan would pay.
  • Naming your own account in account doesn't help. Your account hasn't listed that user, so the quote can't bill it, and the verdict says not-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 ru from the quote. It doesn't depend on who pays. Ignore billedAs and priceMicroUsdc, and wouldBill from /v1/validate.
  • Don't run the check on every action. /v1/quote and /v1/validate allow 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:

preflight.ts
/**
* 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​

pick-tier.ts
/**
* 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:

kindWhen
cap.thresholdUsage crossed 80 % of the cap, and each threshold you configured.
cap.reached100 %.
payg.escrow_low, payg.escrow_emptyPay-as-you-go escrow below its low mark; exhausted.
service.halted, service.resumedThe account stopped being served, or started again.
tariff.scheduledA price increase was scheduled, 48 hours ahead. Cannot be switched off.
renewal.upcoming, renewal.succeeded, renewal.skippedAuto-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​

CodeStatusWaiting helps?Do
RATE_LIMITED429Yes, after Retry-After (also in details.retryAfterMs)Wait, then send the same bytes again.
QUOTA_EXHAUSTED429NoTurn on pay-as-you-go, top up its escrow, or move up a tier.
NO_ENTITLEMENT402NoBuy or renew a plan.
PAYG_PRICE_ABOVE_MAX409Only if the price comes back downRaise your max_payg_price, or wait.

RATE_LIMITED and QUOTA_EXHAUSTED are both 429s but need different actions, so branch on code.