@akira-io/billing-js
v1.5.0
Published
TypeScript client for the Akira Billing API. Pricing, downloads, checkout helpers for landing pages and web apps.
Readme
@akira-io/billing-js
TypeScript client for the Akira Billing API. Two surfaces:
- Storefront helpers (
/pricing,/downloads,/checkout,/react,/vue) — browser-safe, no secret required. For landing pages, marketing sites, and SPAs that only call unauthenticated endpoints. BillingClient(/client) — full HMAC-signed client mirroring the Go and Rust SDKs. For trusted runtimes that hold aproductSecret: Node servers, Next.js route handlers, Cloudflare Workers, Deno, Bun, CLI scripts, controlled webviews. Never ship the secret to a browser bundle.
Zero runtime dependencies. Dual ESM + CJS. Tree-shakeable per-module entry points.
Full reference:
docs/00-index.md— one file per module, with the same numbered structure mirrored in the Rust and Go SDKs.
BillingClient (/client)
Runtime-agnostic class. Works anywhere fetch and crypto.subtle exist (Node 18+,
Bun, Deno, Cloudflare Workers, every modern serverless runtime). Reads the secret
from your environment, never from import.meta.env exposed to a browser bundle.
Serverless route handler — instantiate per request
import { BillingClient } from '@akira-io/billing-js/client';
// app/api/login/route.ts (Next.js route handler — also fits Vercel, Netlify, CF Workers)
export async function POST(req: Request) {
const client = new BillingClient({
baseUrl: process.env.AKIRA_BILLING_URL!,
productSlug: 'unified-dev',
productSecret: process.env.AKIRA_BILLING_SECRET!,
});
const { email, code } = await req.json();
const result = await client.verifyOtp({ email, code });
// store result.access_token in a session cookie
return Response.json(result);
}Long-lived server — instantiate once, reuse
import { BillingClient } from '@akira-io/billing-js/client';
import express from 'express';
const billing = new BillingClient({
baseUrl: process.env.AKIRA_BILLING_URL!,
productSlug: 'unified-dev',
productSecret: process.env.AKIRA_BILLING_SECRET!,
});
const app = express();
app.post('/login', async (req, res) => {
const result = await billing.verifyOtp(req.body);
res.json(result);
});Per-customer token
After OTP verify the SDK stores the bearer on the instance. For follow-up
authenticated calls, either reuse the same client or call setCustomerToken:
client.setCustomerToken(req.cookies.akira_token);
const me = await client.customerMe();
const features = await client.entitlements();Available methods
| Method | Endpoint |
|---|---|
| requestOtp(payload) | POST /api/auth/customer/otp/request |
| verifyOtp(payload) | POST /api/auth/customer/otp/verify (auto-sets bearer) |
| customerMe() | GET /api/me |
| licenseCheck(payload) | POST /api/licenses/check |
| licenseActivate(payload) | POST /api/licenses/activate |
| licenseRefresh(payload) | POST /api/licenses/refresh |
| licenseSyncUsage(payload) | POST /api/licenses/sync-usage (offline_snapshot mode) |
| entitlements() | GET /api/me/entitlements |
| billingPortal(returnUrl) | GET /api/billing/portal |
| trackUsage(payload) | POST /api/me/usage (variable count for tokens/units) |
| publicLicenseKeys() | GET /api/v1/license-keys/public (no HMAC) |
Errors
Non-2xx responses throw BillingApiError with status and code fields populated
from the server error payload.
import { BillingApiError } from '@akira-io/billing-js/client';
try {
await client.licenseActivate({ ... });
} catch (error) {
if (error instanceof BillingApiError && error.code === 'no_active_plan') {
// redirect to upgrade
}
throw error;
}Custom fetch
Pass fetcher to override globalThis.fetch (custom retry, observability, etc):
new BillingClient({ baseUrl, productSlug, productSecret, fetcher: myFetch });Install
bun add @akira-io/billing-js
# or
pnpm add @akira-io/billing-js
# or
npm install @akira-io/billing-jsPricing
import { fetchPricing, formatPrice } from '@akira-io/billing-js/pricing';
const pricing = await fetchPricing({
baseUrl: 'https://billing.akira.foundation',
productKey: 'unified-dev',
tierMeta: {
free: { tagline: 'Local dev', highlighted: false, label: 'Free', order: 10, ctaLabel: 'Download' },
pro: { tagline: 'Unlimited runs', highlighted: true, label: 'Pro', order: 20, ctaLabel: 'Subscribe' },
},
});
pricing.tiers.forEach((tier) => {
const monthly = tier.monthly ? formatPrice(tier.monthly.amount, tier.monthly.currency) : '€0';
console.log(`${tier.name}: ${monthly}/month`);
});The shape of PricingTier mirrors what landing pages render: separate monthly, yearly,
and oneTime slots with the originating planKey so you can build a checkout URL from it.
Downloads
import { triggerDownload } from '@akira-io/billing-js/downloads';
document.querySelector('#download-arm')!.addEventListener('click', () =>
triggerDownload({
baseUrl: 'https://billing.akira.foundation',
product: 'unified-dev',
channel: 'stable',
platform: 'macos-arm64',
query: { utm_source: 'landing' },
}),
);triggerDownload:
- Calls
GET /api/v1/downloads/{product}/{channel}/{platform}withAccept: application/json. - Navigates the current tab to the returned signed URL.
- Schedules
navigator.sendBeaconagainst the returnedbeaconUrlafter a short delay so the backend can mark the event as completed.
Need more control? issueDownload returns the payload without redirecting; downloadUrl
just builds the endpoint URL; sendCompletionBeacon ships the beacon on its own.
Licensing modes
The server tags every product with a licensing_mode:
| Mode | When to use | Client flow |
|---|---|---|
| offline_snapshot | Desktop / IDE-style apps. Long-lived entitlement, infrequent usage events. | Refresh signed snapshot, decrement local counter, sync deltas periodically. |
| online_realtime | Pay-per-unit (AI tokens, API calls). Hard caps + accurate billing. | Pre-check budget + post-commit actual count. |
Offline snapshot helpers (/license)
import {
decodeLicense,
verifyLicense,
computeRemaining,
isExpired,
isInGrace,
canUseUpdate,
periodResetAt,
} from '@akira-io/billing-js/license';
const { license } = await client.licenseRefresh({ product: 'maintainer', fingerprint });
const decoded = decodeLicense(license);
const pub = await client.publicLicenseKeys();
const ok = await verifyLicense(license, pub.keys[0].public_key_base64);
if (!ok) throw new Error('forged license');
// Pre-run gate
const remaining = computeRemaining(decoded.payload, 'agent_run', localConsumed);
if (remaining === 0) throw new Error('limit reached');
// Background sync
await client.licenseSyncUsage({
product: 'maintainer',
fingerprint,
serial: decoded.payload.serial ?? 0,
deltas: { agent_run: 3 },
});Online realtime (variable count)
// Pre-check budget for an AI prompt
const pre = await client.trackUsage({
product: 'aisite',
feature: 'llm_tokens',
device_fp: deviceFingerprint,
date: '2026-05-15',
action: 'check',
count: 4000, // max_tokens estimate
});
if (!pre.allowed) throw new Error('budget exhausted');
const response = await openai.chat.completions.create({ ... });
// Post-commit actuals
await client.trackUsage({
product: 'aisite',
feature: 'llm_tokens',
device_fp: deviceFingerprint,
date: '2026-05-15',
action: 'increment',
count: response.usage.total_tokens,
});Checkout
import { checkoutUrl } from '@akira-io/billing-js/checkout';
const url = checkoutUrl('https://billing.akira.foundation', 'unified-dev', 'pro_monthly');
// → https://billing.akira.foundation/subscribe/unified-dev/pro_monthlyThe billing app handles the rest (guest Stripe Checkout session, redirect, webhook).
React hooks (/react)
import { usePricing, useDownload } from '@akira-io/billing-js/react';
import { formatPrice } from '@akira-io/billing-js/pricing';
function Pricing() {
const { data, isLoading, refresh } = usePricing({
baseUrl: 'https://billing.akira.foundation',
productKey: 'unified-dev',
});
if (isLoading) return <Skeleton />;
return data?.tiers.map((tier) => (
<Card key={tier.key}>
<h3>{tier.name}</h3>
{tier.monthly && <p>{formatPrice(tier.monthly.amount, tier.monthly.currency)}/mo</p>}
<ul>{tier.features.map((f) => <li key={f.key}>{f.name}</li>)}</ul>
</Card>
));
}
function DownloadButton() {
const { trigger, isPending } = useDownload({
baseUrl: 'https://billing.akira.foundation',
product: 'unified-dev',
channel: 'stable',
platform: 'macos-arm64',
});
return <button disabled={isPending} onClick={trigger}>{isPending ? 'Starting…' : 'Download'}</button>;
}Vue composables (/vue)
<script setup lang="ts">
import { usePricing, useDownload } from '@akira-io/billing-js/vue';
import { formatPrice } from '@akira-io/billing-js/pricing';
const { data, isLoading } = usePricing(() => ({
baseUrl: 'https://billing.akira.foundation',
productKey: 'unified-dev',
}));
const { trigger, isPending } = useDownload(() => ({
baseUrl: 'https://billing.akira.foundation',
product: 'unified-dev',
channel: 'stable',
platform: 'macos-arm64',
}));
</script>
<template>
<div v-if="isLoading">Loading…</div>
<div v-else v-for="tier in data?.tiers ?? []" :key="tier.key">
<h3>{{ tier.name }}</h3>
<p v-if="tier.monthly">{{ formatPrice(tier.monthly.amount, tier.monthly.currency) }}/mo</p>
</div>
<button :disabled="isPending" @click="trigger">{{ isPending ? 'Starting…' : 'Download' }}</button>
</template>React and Vue are listed as optional peer dependencies — install only the
one your app needs. The core modules (/pricing, /downloads, /checkout)
have no framework dependency.
Bundled types
import type {
PricingPayload,
PricingTier,
PricingFeature,
TierMeta,
IssuedDownload,
ReleaseChannel,
AssetPlatform,
} from '@akira-io/billing-js';Development
pnpm install
pnpm test
pnpm buildLicense
MIT.
