Developer Documentation
Build applications for ArSite — an intelligence layer for Canada, built in Canada.
Quick start
ArSite apps are hosted: you run your own backend and UI on any infrastructure (Railway, Vercel, Fly, your own metal). ArSite renders your UI in a sandboxed iframe and proxies every API call through its gateway — auth, entitlement, per-cycle metering, and HMAC signing happen on the platform side. You write the iframe, handle the API, and verify HMAC on your backend.
1. Scaffold a new app
npx create-arsite-app \ --id=my-app \ --name="My App" \ --framework=next-iframe \ --dir=./my-app cd my-app npm install npm run dev # iframe app serves at http://localhost:3000/embed
Two templates ship with the scaffolder: next-iframe (Next.js app with iframe entry + API route) and express-api (API only, BYO frontend).
2. Run the local gateway proxy
arsite dev runs a local gateway on :7400 that stamps fake auth + HMAC headers and forwards to your backend. Lets you exercise the full proxy path without deploying.
# in another terminal npx @arsite/cli dev # local gateway on :7400 npx @arsite/cli validate # schema + frame ping + healthcheck
3. Deploy + submit
- Deploy your frontend (the iframe app) and backend to any HTTPS hosts.
- Apply for a developer account at /developer (1–2 business days).
- Create a new app listing — wizard collects basics, integration URLs, media, pricing, legal, then upload your
arsite.config.tsmanifest in the “Frame & API” tab and store your signing secret in the “Secrets” tab. - Submit for review. Automated checks run first; manual review follows.
Publish an app
Publishing is five steps. You always create a listing in the portal; the SDK and CLI are how you author and validate the manifest and code the listing points at.
- 1
Apply for a developer account at /developer
Approval typically takes 1–2 business days. - 2
Create a listing in the new-app wizard
Set metadata, the integration type (hosted with UI, hosted API-only, or listed), media, pricing, and legal links. - 3
Upload your manifest and secrets
In the listing's Frame & API tab, uploadarsite.config.ts; store your signing secret in the Secrets tab. - 4
Submit for review
Automated checks (schema + reachability) run synchronously; manual review follows. See Submission & review for the status machine. - 5
Publish
Approved listings flip topublished, the manifest is written toapp_registry, and the app appears on ArSite Apps.
Choose your authoring path
| UI wizard (/developer/apps/new) | SDK + CLI (create-arsite-app) | |
|---|---|---|
| Best for | Listing-first; simple / listed / API-only apps; non-engineers on the team. | Engineers shipping a hosted iframe app with real routes. |
| Produces | A draft listing + initial pricing tiers. | A scaffolded project + arsite.config.ts you upload to the listing. |
| Manifest | Generated minimally on submit (basics only). | Hand-authored, version-controlled, validated by arsite validate. |
| When | Always required (the listing lives in the portal). | Recommended whenever you have a backend/frontend to host. |
How apps work
Every request from a tenant's iframe takes the same path:
browser (tenant)
→ arsite.ca/{tenantSlug}/apps/{appId}
→ <iframe src="https://yourmod.example.com/embed" sandbox=...>
loads @arsite/sdk/iframe
handshake: postMessage(app:ready) ↔ postMessage(arsite:init { JWT, user, tenant, ... })
arsite.api('/v1/search?q=foo') →
→ arsite.ca/api/apps/{appId}/v1/search?q=foo (Bearer JWT)
1. validate JWT (5-min HS256)
2. resolve manifest from app_registry
3. match route → look up billingUnits
4. check subscription + per-cycle quota
5. decrypt upstream + signing secrets
6. HMAC-sign canonical string
7. forward to your backend with x-arsite-{signature,timestamp,user,tenant,app}
8. record usage_event { units, latency, status }
→ yourbackend.example.com/v1/search?q=foo
verifyArsiteRequest() → user/tenant/appId are trustable headersx-arsite-user / x-arsite-tenant).Integration variants
Every app declares an integration in its manifest. Pick the variant that matches what you are shipping — it decides what renders in the ArSite shell.
| Variant | When to use | Renders in the shell |
|---|---|---|
hosted (frame) | You host a UI and a backend. The default. | Your iframe at the frame route, plus any docs/api/usage tabs. |
hosted (API-only) | A metered REST API with no UI of its own. Set hosted.ui: 'api-only' and omit the frame. | No iframe — Docs and API access tabs only. Must declare at least one non-frame route. |
listed | Your product lives on your own site; ArSite lists it for discovery. Optionally monetize an API via billing.mode: 'rest-proxy'. | An external “Open” link + optional Docs/API/Usage tabs. No iframe. |
native | Reserved for first-party ArgonBI apps built inside the monorepo. | Native pages. |
Tabs
An app declares one or more routes; the shell renders each as a tab. Each route has a kind that selects how it renders:
frame— mounts your iframe atpath(hosted-frame apps only; the only kind that mints an iframe JWT).docs— platform-rendered docs (fromdocsUrlor the listing description). Available to every variant.api— the route table, gateway base, and public REST passthrough, for self-serve API consumers.usage— the caller's usage for this app.
frame tab — otherwise there is nothing to render.SDK · types & manifest
@arsite/sdk/types exports the Zod schema for the hosted-app manifest and a typed factory.
// arsite.config.ts
import { defineHostedApp } from '@arsite/sdk/types';
export default defineHostedApp({
id: 'my-app', // kebab-case, 3–48 chars
name: 'My App',
version: '0.1.0', // semver
description: '…',
icon: 'database', // Lucide icon name
category: 'intelligence', // analytics | monitoring | reporting | intelligence
permissions: ['my-app:read', 'my-app:api'],
routes: [{ path: '/', label: 'Home', icon: 'home' }],
dependencies: [],
integration: 'hosted',
hosted: {
frame: {
url: 'https://my-app.example.com/embed',
initialHeight: 700,
},
upstream: {
baseUrl: 'https://my-app-api.example.com',
auth: { type: 'none' }, // bearer | header | none
// timeoutMs: 30000,
},
signing: { secretRef: 'my-app.signing_secret' },
healthcheck: { path: '/health' },
routes: [
{ method: 'GET', pattern: '/v1/search', billingUnits: 1 },
{ method: 'GET', pattern: '/v1/items/:id', billingUnits: 0.5 },
{ method: 'POST', pattern: '/v1/predict', billingUnits: 5 },
{ method: 'GET', pattern: '/health', billingUnits: 0 },
],
},
});Per-route fields
| Field | Required | Notes |
|---|---|---|
method | yes | GET / POST / PUT / PATCH / DELETE |
pattern | yes | Starts with /. Supports :param segments. First match wins — list more specific routes first. |
billingUnits | yes | Per-call cost. 0 = free. Fractional values are fine (e.g. 0.5). Your backend can override per-response with X-Arsite-Units. |
tierGate | no | List of pricing-tier keys allowed to call this route. |
cache | no | { ttlSeconds, varyByUser?, bill? } |
SDK · iframe runtime
@arsite/sdk/iframe is what your UI imports. createArsiteClient() handshakes with the parent shell and returns a typed client.
'use client';
import { useEffect, useState } from 'react';
import { createArsiteClient, type ArsiteClient } from '@arsite/sdk/iframe';
export default function Embed() {
const [client, setClient] = useState<ArsiteClient | null>(null);
useEffect(() => {
createArsiteClient().then(setClient);
}, []);
useEffect(() => { if (client) client.resize.auto(); }, [client]);
if (!client) return <p>Connecting…</p>;
return (
<div>
<h1>Hi {client.user.name}</h1>
<button onClick={async () => {
const r = await client.api('/v1/search?q=foo');
const json = await r.json();
console.log(json);
}}>Search</button>
</div>
);
}Client surface
| Member | Purpose |
|---|---|
client.user | { id, email, name, role, locale } |
client.tenant | { id, slug, name, planTier } — empty when the caller is acting individually (not under an organization). |
client.app | { id, version, subscriptionTier, quotaRemaining } |
client.api(path, init?) | Signed-fetch wrapper. Adds Authorization: Bearer JWT and prepends the gateway base. Returns a standard Response. |
client.theme | Design tokens (cream/charcoal/forest/...) for visual consistency. |
client.toast.{info,success,error} | Surface a toast in the ArSite shell. |
client.resize.auto() | Auto-resize the iframe based on body height. Returns a disposer for cleanup. |
client.events.on(evt, cb) | Subscribe to tenantChange, themeChange, quotaWarning, visibility. |
React adapters
import {
ArsiteProvider,
useArsite,
useArsiteUser,
useArsiteTenant,
useArsiteApp,
useArsiteApi,
useArsiteTheme,
} from '@arsite/sdk/iframe/react';
export function App() {
return (
<ArsiteProvider>
<Probe />
</ArsiteProvider>
);
}
function Probe() {
const user = useArsiteUser();
const api = useArsiteApi();
return <p>Hi {user?.email}</p>;
}SDK · backend verifier
@arsite/sdk/server exposes one function: verifyArsiteRequest(). Call it at the top of every protected handler. It throws unless the HMAC signature + timestamp are valid; on success it returns the verified user/tenant/app ids.
// app/api/v1/search/route.ts (Next.js)
import { verifyArsiteRequest } from '@arsite/sdk/server';
export async function GET(req: Request): Promise<Response> {
const url = new URL(req.url);
const body = await req.text();
const verified = await verifyArsiteRequest(
{
method: 'GET',
path: url.pathname + url.search,
body,
headers: Object.fromEntries(req.headers),
},
{ signingSecret: process.env.ARSITE_SIGNING_SECRET! },
);
// verified.user.id, verified.tenant.id, verified.appId are now trustable.
return Response.json({ message: `hello ${verified.user.id}` });
}Canonical-string contract (for non-TS backends)
The protocol is small and language-agnostic. Reproduce in any backend with ~20 lines.
canonical = `${method}\n${path}\n${timestamp}\n${sha256_hex(body)}\n${appId}\n${userId}\n${tenantId}`
signature = "sha256=" + hmac_sha256_hex(signing_secret, canonical)
# Headers sent by the gateway:
# x-arsite-signature: sha256={hex}
# x-arsite-timestamp: {unix-seconds}
# x-arsite-user: {clerkUserId}
# x-arsite-tenant: {tenantUuid OR 00000000-0000-0000-0000-000000000000 if user-owned}
# x-arsite-app: {appId}
# x-arsite-protocol-version: 1See the DataFun reference app's service/datafun_api/arsite_auth.py for a complete Python implementation (FastAPI / Starlette).
Native design
Iframe apps can match the ArSite shell without reverse-engineering the design tokens. @arsite/sdk/theme ships the full palette, a Tailwind preset, and a small set of tokenized primitives.
Tokens + live theme
import { arsiteTokens, applyTheme } from '@arsite/sdk/theme';
// Write the shell's live theme to :root as CSS variables (--arsite-forest, …).
// Tracks future dark mode automatically instead of hardcoding hex.
client.theme.apply(); // convenience on the iframe client
// or, manually:
applyTheme(client.theme);Tailwind preset
// tailwind.config.ts
import arsitePreset from '@arsite/sdk/theme/tailwind';
export default {
presets: [arsitePreset], // cream, linen, forest, gold, rounded-xl, Inter, no default blue
content: ['./src/**/*.{ts,tsx}'],
};Primitives
@arsite/sdk/theme/react exports thin, tokenized Button, Card, Input, Callout, Table, and Badge (peer-dep React). If you already have shadcn components, bring your own and just apply the preset.
rounded-xl/rounded-2xl), Inter everywhere, Lucide-only icons.Cross-app composition
An app can build on another publishedArSite app's public API. With the user's consent, your backend calls the upstream app's metered routes through the platform — usage and revenue are attributed correctly to both sides.
Declaring a dependency
// arsite.config.ts (caller app)
appDependencies: [
{
appId: 'upstream-app', // the published app you depend on
routes: ['/v1/data'], // the routes you intend to call
required: true,
reason: 'Fetch base data to enrich our analysis.', // shown to the user at consent
},
],Exposing routes to other apps
An upstream app only shares a route if it opts in. Set app-level exposesPublicApi: true and mark each shareable route composable: true. Only composable routes appear in the service registry and are callable via delegation.
How the call flows
- Your backend mints a delegated token at
POST /api/apps/{callerAppId}/delegate/{upstreamAppId}/tokenfor the current subject, then calls/api/apps/{upstreamAppId}/{...path}with it. - The gateway requires the matched route to be
composableand an active grant covering it; otherwise403. - Billing: usage is charged to the subject's subscription to the upstream app (so its developer earns). The subject must be subscribed to both apps.
appDependencies always get full (not expedited) manual review — the reviewer confirms the upstream app exposes the requested routes as composable and that the stated reason is accurate.@arsite/cli
| Command | Purpose |
|---|---|
npx @arsite/cli dev | Local gateway proxy on :7400. Reads your arsite.config.ts, stamps fake user/tenant + HMAC headers, forwards requests to your local backend. Set ARSITE_SIGNING_SECRET to a value your backend also has. |
npx @arsite/cli validate | Validates the manifest against the Zod schema and pings frame URL + upstream healthcheck. Use --ping=false to skip network checks. |
npx @arsite/cli logs | Tails usage events for an app (SSE). Requires ARSITE_DEV_KEY + --id=<appId>. |
Gateway protocol
Request lifecycle
- JWT validation — HS256, issued by the platform, 5-minute TTL, audience
app-iframe, signed withARSITE_JWT_SIGNING_KEY. - App match — manifest is loaded from the
app_registrytable by id. - Route match — first-match-wins against
hosted.routes[]. - Subscription + quota— looks up the caller's subscription; UPSERTs into
app_subscription_usage; refunds + returns 429 if over the included monthly units. - HMAC sign— canonical string SHA-256 HMAC with the app's signing secret.
- Forward — adds dev-declared upstream auth (Bearer or custom header) + the
x-arsite-*headers, hits your backend. - Usage — writes a row to
usage_eventswith the billed units (overridable byX-Arsite-Unitsresponse header for variable-cost ops like LLM tokens).
Response codes
| Status | Meaning |
|---|---|
| 200–2xx | Pass-through from your backend. |
| 401 | Missing or invalid JWT. |
| 403 not subscribed | Caller is not subscribed to this app. |
| 404 route not declared | Path/method not in the manifest's hosted.routes. |
| 429 quota exceeded | Monthly units exhausted. Retry-After + X-Arsite-Quota-Reset headers indicate cycle end. |
| 503 feature disabled | Platform-wide kill switch (MARKETPLACE_HOSTED_MODULES_ENABLED=false). |
| 504 upstream timeout | Your backend didn't respond within hosted.upstream.timeoutMs (default 30s). |
Submission & review
Stage 1 — Automated checks (synchronous)
- Listing fields (name, description, screenshots, category, pricing) complete.
- Manifest passes the Zod schema in @arsite/sdk/types.
- Frame URL responds (
HEADwithin 10s). - Upstream healthcheck (
baseUrl + healthcheck.path) returns the expected status (default200).
On failure the submission flips to auto_check_failedand won't enter the manual queue. Fix and resubmit.
Stage 2 — Manual ArgonBI review
- Typical turnaround: 2–5 business days.
- Approve / Reject / Request changes. Apps in
changes_requestedcan be resubmitted directly. - Approved listings flip to
publishedand appear on ArSite Apps. The manifest is written toapp_registryatomically.
Updating an app
- Bump
versioninarsite.config.tsand re-submit through the “Frame & API” tab. - Patch/minor bumps: expedited review.
- Major bumps (route changes, permission changes, pricing changes): full review again.
Sandbox
Two ways to run an app without installing it — they serve different audiences:
- Dev preview — from your app's edit page, click Preview. Renders your app in the platform shell for 15 minutes using your own user as the test subject. Owner-only; no cooldown; no install.
- End-user trial — any signed-in user on
/apps/{appId}can hit Try for 15 minutes. Capped at 50 calls and one trial per (user, app) per day. Disable it per-app from the Preview card on your edit page (e.g. enterprise-only or cost-heavy apps).
| Dev preview | End-user trial | |
|---|---|---|
| Route | /api/sandbox/dev/{appId}/token | /api/sandbox/trial/{appId}/token |
| Who | App owner only. | Any signed-in user from /apps/{appId}. |
| Duration | 15 min | 15 min |
| Call cap | dev cap | 50 calls |
| Cooldown | none | one trial per (user, app) per day |
| Billing | units = 0 (sandbox_call) | units = 0 (sandbox_call) |
| Disable | always available to owner | per-app via Disable trial sandbox |
What your backend sees
Sandbox traffic goes to the same upstream URL as production. The gateway adds two headers so you can branch on them:
X-Arsite-Sandbox: true X-Arsite-Sandbox-Mode: dev | trial
Read-only apps (search, lookups) work in sandbox with zero changes. Apps that mutate state should detect the header and either no-op, return mock data, or write to a sandbox-only table.
Pricing & metering
Set tiers in the “Pricing” tab on your app's edit page. Each tier declares a fixed included_monthly_units quota. The gateway atomically increments per-cycle units on every billable call and refunds + returns 429 if a request would push usage over the cap.
Variable-cost routes
For operations whose cost is data-dependent (LLM tokens, report rows), return X-Arsite-Units: 42on the response and the gateway will bill that value instead of the manifest's declared billingUnits.
// Express example
app.post('/v1/predict', async (req, res) => {
const verified = await verifyArsiteRequest(...);
const result = await runLLM(req.body);
res.set('X-Arsite-Units', String(result.tokensUsed));
res.json({ result: result.text });
});Public REST API
https://arsite.ca/api/v1/openapi.json.The public REST API is what externalcallers (your own scripts, a CI job, a customer's server) use to query published apps — separate from the iframe runtime. Auth is via a per-tenant API key.
Base URL
https://arsite.ca/api/v1/
Authentication
Authorization: Bearer arsite_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Generate keys in Dashboard → API Keys. Keys are user-owned (optionally tenant-scoped) and can be scoped to specific apps.
Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /health | None | Platform health. |
| GET | /apps | Bearer | List published apps. |
| GET | /apps/{appId} | Bearer | App metadata + pricing tiers. |
| GET | /usage | Bearer | This tenant's API-call usage for the current cycle. |
| GET/POST | /apps/{appId}/{...path} | Bearer | Passthrough to the app's upstream API (only for apps you have an active subscription to and the key is scoped to). |
Rate limits
Per-key sliding-window limit (default 1,000 req/hr). Exceeding returns 429 with Retry-After. Tier-specific limits are configurable on the key.
OAuth (developer API)
Developer accounts get OAuth client credentials so backend tooling can mint access tokens without storing a long-lived API key. Find your Client ID, Client Secret, and Refresh Token in Developer → Settings.
client_credentials grant
curl -X POST https://arsite.ca/api/oauth/token \
-H 'content-type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "arsite_cid_…",
"client_secret": "arsite_cs_…"
}'
# → { "access_token": "...", "token_type": "Bearer", "expires_in": 3600 }refresh_token grant
curl -X POST https://arsite.ca/api/oauth/token \
-H 'content-type: application/json' \
-d '{
"grant_type": "refresh_token",
"client_id": "arsite_cid_…",
"refresh_token":"eyJ…"
}'
# → { "access_token": "...", "refresh_token": "...new...", "token_type": "Bearer", "expires_in": 3600 }Access tokens are 1-hour JWTs with scopes api:invoke, read:modules. Refresh tokens rotate on every use.
Revenue & payouts
ArSite's revenue share is configurable per tier. Standard developers keep —%; trusted developers keep —%. Admins may set a custom per-developer rate.
Stripe Connect
- Start onboarding at Developer → Revenue. Stripe requires identity verification and a bank account.
- Free apps don't need Stripe Connect.
- Payouts on the 1st of each month, $50 CAD minimum, 2–5 business day transfer.