@chatarmin/os
Type-safe SDK for ChatarminOS — the all-in-one platform for B2B SaaS to manage customers, subscriptions, feature access, and billing.
Features
- 🏢 Company Management — Create, link, and manage customer companies
- 🎫 Subscription Tiers — Define tiers with features and Stripe pricing
- ✨ Feature Access — Check entitlements and enforce usage limits
- 📊 Usage Tracking — Metered billing with idempotency support
- 💳 Stripe Integration — Claim checkout sessions, link subscriptions
- 🔗 Smart Linking — Intelligent company matching by domain/name/email
- 👥 Contact Sync — Bulk upsert contacts for each company
- 🚀 Unified Onboarding — Complete setup in a single API call
Table of Contents
- Installation
- Quick Start
- API Reference
- Common Patterns
- TypeScript Support
- Configuration
- Error Handling
Installation
pnpm add @chatarmin/os
# or
npm install @chatarmin/os
# or
yarn add @chatarmin/os
Quick Start
import { ChatarminOS } from "@chatarmin/os"
const os = new ChatarminOS({
apiKey: process.env.OS_API_KEY!,
// Optional: custom base URL for self-hosted
// baseUrl: 'https://your-os.example.com/api/v1'
})
// Onboard a new customer (single call does everything!)
const result = await os.onboard({
externalOrgId: "org_abc123", // Your product's org ID
label: "My Product", // Display label for the product link (required)
hints: {
companyName: "Acme Inc",
domain: "acme.com",
},
tierCode: "free",
contactEmail: "[email protected]",
contactName: "John Doe",
})
console.log(result.companyId) // Use this for all future API calls
console.log(result.linkStatus) // 'created' | 'already_linked'
API Reference
Companies
Manage customer companies and their links to your product.
companies.get(input)
Look up a company by your external organization ID or ChatarminOS company ID.
const company = await os.companies.get({
externalOrgId: "org_abc123",
})
if (company) {
console.log(`Found: ${company.name} (${company.id})`)
} else {
console.log("Company not linked yet")
}
companies.create(input)
Create a new company and link it to your product.
const company = await os.companies.create({
name: "Acme Inc",
domain: "acme.com",
externalOrgId: "org_abc123",
label: "My Product", // Display label for the product link
contactEmail: "[email protected]",
contactName: "John Doe",
createdAt: "2024-01-15T10:30:00.000Z", // Optional: historical date
})
console.log(company.id) // UUID
console.log(company.shortId) // e.g., "acme-inc"
companies.update(input)
Update company metadata (e.g., backfill historical dates).
await os.companies.update({
companyId: "uuid-here",
createdAt: "2023-06-01T00:00:00.000Z",
})
Contacts
Manage people associated with companies.
contacts.create(input)
Create or update a single contact.
const contact = await os.contacts.create({
companyId: "comp_xxx",
email: "[email protected]",
name: "John Doe",
role: "CEO",
isPrimary: true,
avatarUrl: "https://example.com/avatar.jpg",
metadata: { slackId: "U123" },
})
if (contact.isNew) {
console.log("Created new contact")
} else {
console.log("Updated existing contact")
}
contacts.bulkUpsert(input)
Bulk create or update multiple contacts. Recommended for onboarding.
const result = await os.contacts.bulkUpsert({
companyId: "comp_xxx",
contacts: [
{ email: "[email protected]", name: "Alice", role: "CEO", isPrimary: true },
{ email: "[email protected]", name: "Bob", role: "CTO" },
{ email: "[email protected]", name: "Charlie", role: "Engineer" },
],
})
console.log(`Created: ${result.created}, Updated: ${result.updated}`)
// result.contacts contains individual results with IDs
contacts.list(companyId, primaryOnly?)
List contacts for a company.
// Get all contacts
const contacts = await os.contacts.list("comp_xxx")
// Get only the primary contact
const [primary] = await os.contacts.list("comp_xxx", true)
Features
Check and manage feature access for companies.
Identifiers: pass featureCode on input (e.g. "ai_credit"). Responses include featureCode and featureAccessId. Codes are set at feature create and are not renamed via the API — change display with featureName, or create a new feature and migrate for a real rename. See product-billing-api.md for details.
features.check(input)
Check if a company has access to a specific feature.
const access = await os.features.check({
companyId: "comp_xxx",
featureCode: "ai_credit",
})
console.log({
enabled: access.isEnabled, // Feature is turned on
canUse: access.canUse, // Can use right now (not at limit)
hasQuantity: access.hasQuantity, // Is a quantity-based feature
usage: access.currentUsage, // Current usage count
limit: access.quantityLimit, // Max allowed (null = unlimited)
included: access.includedQuantity, // Free tier before billing
remaining: access.remaining, // How many left (null = unlimited)
})
if (!access.canUse) {
throw new Error("Upgrade required")
}
features.listAccess(companyId)
List all features and their access status for a company.
const features = await os.features.listAccess("comp_xxx")
for (const f of features) {
console.log(`${f.featureName}: ${f.isEnabled ? "✓" : "✗"}`)
if (f.quantityLimit) {
console.log(` Usage: ${f.currentUsage} / ${f.quantityLimit}`)
}
}
features.setAccess(input)
Update feature access with partial config merge.
// Update by company ID
await os.features.setAccess({
companyId: "comp_xxx",
featureCode: "ai_credit",
isEnabled: true,
quantityLimit: 1000,
includedQuantity: 100,
config: {
model: "gpt-4",
maxTokens: 4096,
},
source: "manual", // 'subscription' | 'manual' | 'trial' | 'api'
validUntil: new Date("2025-12-31"), // null for no expiration
})
// Update by external org ID
await os.features.setAccess({
externalOrgId: "org_abc123",
featureCode: "team_seats",
config: { maxSeats: 50 }, // Only updates maxSeats, preserves other config
})
features.getAccessByExternalOrgId(externalOrgId)
Get all features using your product's external org ID.
const result = await os.features.getAccessByExternalOrgId("org_abc123")
console.log(`Company: ${result.companyId}`)
for (const f of result.features) {
console.log(`${f.featureCode}: ${f.isEnabled}`)
console.log(" Config:", f.configValues)
}
features.checkByExternalOrgId(externalOrgId, featureCode)
Check a specific feature using your external org ID.
const access = await os.features.checkByExternalOrgId("org_abc123", "ai_credit")
if (access.canUse) {
console.log(`Remaining: ${access.remaining}`)
}
Billing
Track usage, claim subscriptions, and manage billing.
API reference:
docs/engineering/product-billing-api.md— full request/response shapes for everyos.billing.*method.
billing.trackUsage(input)
Track usage for a metered feature.
const result = await os.billing.trackUsage({
companyId: "comp_xxx",
featureCode: "ai_credit",
quantity: 5,
idempotencyKey: `request_${requestId}`, // Prevents double-counting
metadata: { model: "gpt-4", tokens: 1500 },
})
console.log({
currentUsage: result.currentUsage,
remaining: result.remaining,
billable: result.billableQuantity, // Amount beyond included tier
isAtLimit: result.isAtLimit,
})
billing.claimCheckout(input)
Claim a subscription from a completed Stripe Checkout Session.
// In your Stripe checkout success handler
const result = await os.billing.claimCheckout({
companyId: "comp_xxx",
checkoutSessionId: "cs_test_xxx", // From Stripe callback
})
if (result.alreadyClaimed) {
console.log("Subscription was already claimed")
} else {
console.log(`Claimed subscription: ${result.subscriptionId}`)
}
billing.linkSubscription(input)
Link an existing Stripe subscription to a company.
// When you create subscriptions on YOUR Stripe account
const result = await os.billing.linkSubscription({
companyId: "comp_xxx",
stripeSubscriptionId: "sub_xxx",
stripeCustomerId: "cus_xxx", // Optional: fetched from subscription if omitted
tierCode: "pro", // Apply tier features
displayName: "Pro Plan",
})
console.log({
subscriptionId: result.subscriptionId,
isNew: !result.alreadyLinked,
featuresApplied: result.featuresApplied,
})
billing.getStatus(companyIdOrInput)
Quick Stripe subscription status for a company — activeSubscriptions (active / trialing / past_due), resolved billing profile, and enabled feature count. Scoped to the product link's billing profile when externalOrgId is provided.
See Product-scoped billing profile — getStatus example for the full walkthrough + sample response.
const status = await os.billing.getStatus('ee0f4a19-2b69-4969-8807-d0013f1670ee')
const statusByOrg = await os.billing.getStatus({ externalOrgId: 'org_kKZHiUHBSjdu' })
if (status.activeSubscriptions[0]?.status === 'past_due') {
// gate access for this external org
}
console.log({
stripeCustomer: status.billingProfile?.stripeCustomerId,
subscriptionStatus: status.activeSubscriptions[0]?.status,
features: status.enabledFeaturesCount,
})
billing.getOverview(input)
Combined billing page payload: company, billing profile, primary subscription, and recent invoices.
const overview = await os.billing.getOverview({
companyId: "comp_xxx",
invoiceLimit: 12,
includeUsage: true, // optional: embed usage rows
})
console.log({
plan: overview.subscription?.tier?.name,
status: overview.subscription?.status,
paymentMethod: overview.billingProfile?.primaryPaymentMethod,
invoices: overview.invoices.length,
usageRows: overview.usage?.length,
})
companies.list / companies.search
const newest = await os.companies.list({ sort: 'createdAt', limit: 50 })
const matches = await os.companies.search({ query: 'acme', sort: 'createdAt' })
sort defaults to id. createdAt is newest first, and the cursor includes the timestamp so ties do not skip rows.
companies.update / companies.listLinks
await os.companies.update({
externalOrgId: 'org_abc123',
name: 'Acme GmbH',
domain: 'acme.com',
})
const links = await os.companies.listLinks({ externalOrgId: 'org_abc123' })
contacts.update / contacts.unlink
await os.contacts.update({
companyId: company.id,
email: '[email protected]',
role: 'Owner',
})
await os.contacts.unlink({ companyId: company.id, email: '[email protected]' })
unlink removes the contact from that company. The person record is deleted only when no other company still uses it.
billing.listClientUsage(input)
Usage for every linked client in one period. Prefer this over looping getUsage for admin glances. startingOn is inclusive, endingBefore is exclusive, and the span is at most 366 days. The API key scopes the call to its product; set includeAllProducts: true to include every product in that organization. Optional companyIds and externalOrgIds narrow the result. Omit featureCode to return every feature that had usage.
const usage = await os.billing.listClientUsage({
startingOn: new Date('2026-06-01T00:00:00.000Z'),
endingBefore: new Date('2026-07-01T00:00:00.000Z'),
includeAllProducts: true,
})
const filtered = await os.billing.listClientUsage({
startingOn: new Date('2026-06-01T00:00:00.000Z'),
endingBefore: new Date('2026-07-01T00:00:00.000Z'),
companyIds: ['f9c9b060-6f83-4b57-a34a-0d84951767cb'],
externalOrgIds: ['org_FVCNOtEdpeeP'],
featureCode: 'ai_credit',
})
usage.companies[].links[].features[] includes usage, includedQuantity, billableQuantity, and estimatedOverageCents. The allowance is the entitlement quantity, not pro-rated to the window.
billing.listClientRevenue(input)
Staff read: what each client was invoiced, paid and refunded, built from the Stripe invoices OS mirrors. The scope is the API key product's organization. Clients with a linked product link of that product are cx_linked; all others are no_cx_link and are totalled separately.
Each client has one entry per currency. Net pre-tax revenue is split into subscription, metered (per meterCode/featureCode, e.g. chatarmin_ai_02), setup-fee and one-time amounts. periodBasis is invoice_period by default (Stripe's invoice period, i.e. the usage billed in arrears) or paid_at. Pages are fetched with limit and cursor, and totals cover every page. completeness.issues explains what may be missing.
const revenue = await os.billing.listClientRevenue({
startingOn: new Date('2026-03-01T00:00:00.000Z'),
endingBefore: new Date('2026-04-01T00:00:00.000Z'),
externalOrgIds: ['org_FVCNOtEdpeeP'],
})
const next = revenue.nextCursor
? await os.billing.listClientRevenue({
startingOn: new Date('2026-03-01T00:00:00.000Z'),
endingBefore: new Date('2026-04-01T00:00:00.000Z'),
cursor: revenue.nextCursor,
})
: null
See docs/engineering/product-billing-api.md for the line classification, the completeness codes and the refund backfill.
billing.getSalesCommission(input)
Staff read: one month's commission per account executive, computed when read. License lines of invoices paid in the month count for the AE who owned the client's deal in Pipedrive when it was won; license credited or refunded in the month is deducted. Usage (AI, contacts, Meta), setup fees and other one-off charges never count.
const october = await os.billing.getSalesCommission({
month: '2026-10',
accountExecutives: ['1001', '1002'], // Pipedrive user ids
dealsWonFrom: '2026-05-01',
sdrs: ['2001'],
})
// october.accountExecutives[i]: { name, paidMrrCents, deductionMrrCents, netMrrCents, payments, deductions }
See docs/engineering/product-billing-api.md for the attribution and line rules.
billing.getUsage(companyIdOrInput, options?)
Metronome-style usage aggregates. Returns { data: UsageRecord[] } — feature-first nesting with per-feature billing cycles.
Company scope: pass companyId as the first string argument, or pass an input object with companyId or externalOrgId (one required). Prefer externalOrgId when CX only knows the org slug — usage is scoped to that product link.
// CX-native (single product link)
const { data } = await os.billing.getUsage({
externalOrgId: 'org_FVCNOtEdpeeP',
windowSize: 'none',
})
// Or companyId as first arg
const byCompany = await os.billing.getUsage('comp_xxx', { windowSize: 'none' })
data[].featureCode— stable feature slugdata[].cycles[]— billing cycles for this feature (periodOffset, bounds,usage[])
windowSize controls whether usage points are period totals or time buckets. Filter with featureCode (stable slug), not featureAccessId — see Feature identifiers.
periodOffsetRange (time scope for whole billing cycles):
| Intent | periodOffsetRange |
|---|---|
| Current cycle (default) | omit or { from: 0, to: 0 } |
| Previous only | { from: -1, to: -1 } |
| Current + previous | { from: -1, to: 0 } |
| 12-cycle picker | { from: -11, to: 0 } |
Multi-cycle range requires windowSize: 'none' (max 12 cycles). Cannot combine with startingOn/endingBefore.
Every cycles[] bucket includes subscriptionItem and summary when it has usage rows. usage[] stays thin (value + dates).
Response shape:
interface UsageQueryResult {
data: UsageRecord[]
}
interface UsageRecord {
featureCode: string
featureName: string
cycles: UsageCycleBucket[]
}
interface UsageCycleBucket {
periodOffset: number
startingOn: Date
endingBefore: Date
label: string
subscriptionItem?: UsageSubscriptionItem // present when cycle has usage rows
summary?: UsageCycleSummary // totals + projection; present when cycle has usage rows
usage: UsagePoint[] // thin: value + dates (+ optional identity/grouping)
}
interface UsagePoint {
value: number
startingOn: Date
endingBefore: Date
featureAccessId?: string
meterCode?: string
groupKey?: string | null // with groupBy: 'productLink'
groupValue?: string | null
productLinkId?: string | null // groupBy: 'productLink', or bucketed multi-link companyId
productLinkLabel?: string | null
externalOrgId?: string | null
}
Read billing from cycle.summary and cycle.subscriptionItem — not from usage[] points.
productLinkId (+ label, externalOrgId) appears on usage[] points when groupBy: 'productLink', or on bucketed companyId queries when multiple product links share the company (without groupKey).
windowSize | cycles[].usage | Billing context |
|---|---|---|
'none' | 1 point | subscriptionItem + summary (always) |
'day' / 'week' / 'month' | N thin points | Same — billing once per cycle, not per bucket |
Examples by windowSize
windowSize: 'none' — period totals:
{
"data": [{
"featureCode": "messages",
"featureName": "Messages",
"cycles": [{
"periodOffset": 0,
"startingOn": "2026-07-01T00:00:00.000Z",
"endingBefore": "2026-08-01T00:00:00.000Z",
"label": "Jul 1, 2026 – Aug 1, 2026",
"subscriptionItem": {
"includedQuantity": 100,
"unitAmountCents": 500,
"currency": "eur"
},
"summary": {
"totalUsage": 120,
"billableQuantity": 20,
"estimatedOverageCents": 10000,
"projectedBillableQuantity": 240,
"projectedOverageCents": 70000
},
"usage": [{
"value": 120,
"startingOn": "2026-07-01T00:00:00.000Z",
"endingBefore": "2026-08-01T00:00:00.000Z"
}]
}]
}]
}
windowSize: 'day' — daily chart buckets:
{
"data": [{
"featureCode": "messages",
"featureName": "Messages",
"cycles": [{
"periodOffset": 0,
"subscriptionItem": { "includedQuantity": 100 },
"summary": { "totalUsage": 20, "billableQuantity": 0 },
"usage": [
{ "value": 8, "startingOn": "2026-07-01T00:00:00.000Z", "endingBefore": "2026-07-02T00:00:00.000Z" },
{ "value": 12, "startingOn": "2026-07-02T00:00:00.000Z", "endingBefore": "2026-07-03T00:00:00.000Z" }
]
}]
}]
}
Multi-cycle (periodOffsetRange: { from: -1, to: 0 }):
{
"data": [{
"featureCode": "messages",
"featureName": "Messages",
"cycles": [
{ "periodOffset": 0, "label": "Jul 1 – Aug 1", "summary": { "totalUsage": 120, "billableQuantity": 20 }, "usage": [{ "value": 120 }] },
{ "periodOffset": -1, "label": "Jun 1 – Jul 1", "summary": { "totalUsage": 98, "billableQuantity": 0 }, "usage": [{ "value": 98 }] }
]
}]
}
Full examples: docs/engineering/product-billing-api.md#billinggetusage
const { data } = await os.billing.getUsage('comp_xxx', { windowSize: 'none' })
const messages = data.find((r) => r.featureCode === 'messages')
const cycle = messages?.cycles[0]
const currentTotal = cycle?.usage[0]?.value
const overage = cycle?.summary?.billableQuantity
const cost = cycle?.summary?.estimatedOverageCents
const daily = await os.billing.getUsage('comp_xxx', {
windowSize: 'day',
featureCode: 'messages',
})
const dailySeries = (daily.data[0]?.cycles[0]?.usage ?? []).map((point) => ({
day: point.startingOn.toISOString().slice(0, 10),
usage: point.value,
}))
// Previous billing period totals
const lastMonth = await os.billing.getUsage('comp_xxx', {
windowSize: 'none',
periodOffsetRange: { from: -1, to: -1 },
})
// Last 12 billing cycles (cycle picker)
const history = await os.billing.getUsage('comp_xxx', {
windowSize: 'none',
periodOffsetRange: { from: -11, to: 0 },
})
// history.data[0].cycles → one bucket per period; cycle.usage[0] → period total
// Custom sub-range within the current billing period (both dates required)
// usage[] points cover the sub-range; summary totals match it; projections use the full cycle
const week = await os.billing.getUsage('comp_xxx', {
windowSize: 'day',
featureCode: 'messages',
startingOn: new Date('2026-07-10T00:00:00Z'),
endingBefore: new Date('2026-07-17T00:00:00Z'),
})
windowSize | Shape |
|---|---|
'none' | One point per feature (collapsed total when multiple product links share a company; use groupBy: 'productLink' for per-link rows) |
'day' / 'week' / 'month' | One point per feature per bucket; one per link per bucket when multiple product links share the company (productLinkId on each point) |
| Time scope | When to use |
|---|---|
| omit | Current billing period (default) |
periodOffsetRange: { from: -1, to: -1 } | Whole previous billing period |
periodOffsetRange: { from: -11, to: 0 } | Last 12 billing cycles |
startingOn + endingBefore | Sub-range inside the current period (clamped; totals follow sub-range; projections use full cycle) |
billing.exportUsage(companyIdOrInput, options?)
Export usage aggregates as CSV. Accepts the same company scope and query options as getUsage (plus implicit format: 'csv'). CSV includes a periodOffset column (first column) for multi-cycle exports. Filename uses exported period bounds: usage_{windowSize}_{companyId}_{periodStart}_{periodEnd}.csv.
const csv = await os.billing.exportUsage({
externalOrgId: 'org_FVCNOtEdpeeP',
windowSize: 'none',
periodOffsetRange: { from: -11, to: 0 },
})
// csv.filename → "usage_none_<companyId>_2026-06-01_2026-08-01.csv"
// csv.content → first line:
// periodOffset,featureCode,featureName,value,startingOn,endingBefore,groupKey,groupValue,productLinkId,productLinkLabel,externalOrgId,includedQuantity,billableQuantity,quantityLimit,remaining,percentUsed,unitAmountCents,currency,estimatedOverageCents,projectedBillableQuantity,projectedOverageCents
billing.listInvoices(companyIdOrInput, options?)
List invoices for a company (excludes draft/void).
const { invoices } = await os.billing.listInvoices("comp_xxx", { limit: 24 })
billing.getPaymentMethod(companyIdOrInput)
Payment method summary for the resolved billing profile.
const { billingProfile } = await os.billing.getPaymentMethod("comp_xxx")
billing.createPortalSession(input)
Open Stripe Customer Portal for manage-billing flows. returnUrl must be on an allowed product host (products.metadata.billing.returnUrlHosts).
const { url } = await os.billing.createPortalSession({
companyId: "comp_xxx",
returnUrl: "https://app.example.com/settings/billing",
})
// When a company has multiple Stripe customers, target a specific billing profile:
const { url: profileUrl } = await os.billing.createPortalSession({
companyId: "comp_xxx",
stripeCustomerId: "cus_xxx",
returnUrl: "https://app.example.com/settings/billing",
})
// Redirect the user to url
billing.resolveInvoicePaymentUrl(input)
Resolve a hosted invoice payment URL for a specific invoice.
const { hostedInvoiceUrl } = await os.billing.resolveInvoicePaymentUrl({
companyId: "comp_xxx",
invoiceId: "inv_xxx",
})
billing.listPastDuePaymentUrls(companyIdOrInput)
List overdue invoices with payment URLs.
const { items } = await os.billing.listPastDuePaymentUrls("comp_xxx")
billing.sendPastDueInvoice(input)
Email a past-due invoice via Stripe (when collection method is send_invoice).
await os.billing.sendPastDueInvoice({
companyId: "comp_xxx",
invoiceId: "inv_xxx",
})
billing.syncCompanyFromStripe(input)
Pull billing profile, subscriptions, and feature links from Stripe.
const result = await os.billing.syncCompanyFromStripe({
companyId: "comp_xxx",
stripeCustomerId: "cus_xxx",
})
console.log({
subscriptionsSynced: result.subscriptionsSynced,
featuresLinked: result.featuresLinked,
})
billing.relinkBillingProfile(input)
Replace the company's billing profile with a new Stripe customer (destructive).
await os.billing.relinkBillingProfile({
companyId: "comp_xxx",
stripeCustomerId: "cus_new_xxx",
})
billing.ensureFeatureSubscription(input)
Ensure a metered feature is linked to a Stripe subscription item.
const result = await os.billing.ensureFeatureSubscription({
companyId: "comp_xxx",
featureCode: "ai_credit",
})
console.log(result.action) // 'already_linked' | 'linked_existing' | 'created_new' | 'failed'
billing.setUsage(input)
Set absolute usage (corrections / admin). Requires a reason for audit.
const result = await os.billing.setUsage({
companyId: "comp_xxx",
featureCode: "messages",
absoluteValue: 420,
reason: "Reconcile after import",
})
console.log({
previous: result.previousUsage,
current: result.currentUsage,
delta: result.delta,
})
billing.getUsageHistory(companyId, featureCode, options?)
Raw usage events for audit/debug (not aggregated rollups).
const events = await os.billing.getUsageHistory("comp_xxx", "messages", {
limit: 50,
eventType: "increment",
})
Subscriptions & Tiers
Manage subscription tiers and apply features.
tiers.list()
List all available tiers for your product.
const tiers = await os.tiers.list()
for (const tier of tiers) {
console.log(`${tier.name} (${tier.code})`)
}
// ['Free', 'Pro', 'Enterprise']
tiers.get(tierCode)
Get detailed tier information including Stripe prices.
const tier = await os.tiers.get("pro")
// Check base fee
if (tier.baseFee) {
console.log(`Base fee: ${tier.baseFee.stripe.unitAmount / 100}€/mo`)
console.log(`Price ID: ${tier.baseFee.stripe.priceId}`)
}
// Access features
for (const feature of tier.features) {
console.log(`${feature.code}:`, feature.configValues)
}
// Get all Stripe price IDs (base fee + feature prices)
console.log("Stripe prices:", tier.stripePriceIds)
// ['price_base', 'price_feature1', 'price_feature2']
tiers.apply(tierCode, options)
Apply a tier's features to a company (without Stripe).
// Apply free tier
await os.tiers.apply("free", {
companyId: "comp_xxx",
})
// Apply with custom limits
await os.tiers.apply("trial", {
companyId: "comp_xxx",
features: {
ai_credit: { included_quantity: 100 },
seats: { max_quantity: 10 },
},
})
subscriptions.create(tierCode, options)
Alias for tiers.apply() — apply a subscription tier.
await os.subscriptions.create("pro", {
companyId: "comp_xxx",
features: {
ai_credit: { included_quantity: 500 },
},
})
Links
Manually manage product links.
links.create(input)
Create a manual link between your external org ID and an existing company.
await os.links.create({
companyId: "comp_xxx",
externalOrgId: "org_abc123",
label: "Store Vienna", // Optional: display label
externalUserId: "user_xyz", // Optional: user who created the link
})
links.reset(options?)
Delete all product links for your product. Use before re-importing.
// Basic reset (links only)
const result = await os.links.reset()
console.log(`Deleted ${result.linksDeleted} links`)
// Full reset (links + contacts + MRR)
const result = await os.links.reset({
deleteContacts: true,
resetMrr: true,
})
console.log(
`Deleted ${result.linksDeleted} links, ${result.contactsDeleted} contacts`
)
Onboarding
Fast customer onboarding for signup.
The onboard() method handles:
- Smart company matching/creation (idempotent on
externalOrgId) - Product linking
- Free-tier feature access when
tierCode: 'free'(no Stripe) - Optional Stripe claim/link when checkout or subscription IDs are provided
Paid billing setup is separate: companies.setupOnboardBilling() (idempotent retry).
Basic Usage
const result = await os.onboard({
externalOrgId: "org_abc123", // Your product's org ID (required)
label: "My Product", // Display label for the product link (required)
hints: {
companyName: "Acme Inc",
domain: "acme.com",
emails: ["[email protected]"],
},
tierCode: "free",
contactEmail: "[email protected]",
contactName: "John Doe",
createdAt: "2024-01-15T10:30:00.000Z", // Optional: historical date
})
console.log({
companyId: result.companyId,
companyName: result.companyName,
linkStatus: result.linkStatus, // 'created' | 'already_linked'
tierApplied: result.billingStatus?.tierApplied,
})
Scenario 1: User Completed Stripe Checkout
const result = await os.onboard({
externalOrgId: "org_abc123",
label: "My Product",
hints: { companyName: "Acme Inc", domain: "acme.com" },
checkoutSessionId: "cs_test_xxx", // From Stripe checkout callback
})
// result.billingStatus.claimed = true
// result.billingStatus.subscriptionId = 'sub_xxx'
Scenario 2: Free Tier Signup
const result = await os.onboard({
externalOrgId: "org_abc123",
label: "My Product",
hints: { companyName: "Acme Inc" },
tierCode: "free",
contactEmail: "[email protected]",
})
// result.billingStatus.tierApplied = true
Scenario 3: You Create Stripe Subscription
// 1. Get tier prices
const tier = await os.tiers.get("pro")
// 2. Create subscription on YOUR Stripe account
const stripeSub = await stripe.subscriptions.create({
customer: stripeCustomerId,
items: tier.stripePriceIds.map((price) => ({ price })),
})
// 3. Onboard with the subscription
const result = await os.onboard({
externalOrgId: "org_abc123",
label: "My Product",
hints: { companyName: "Acme Inc" },
stripeSubscriptionId: stripeSub.id,
stripeCustomerId: stripeCustomerId,
tierCode: "pro",
})
// result.billingStatus.linked = true
// result.billingStatus.tierApplied = true
Workspaces Created by Staff
A new company is tagged acquisition: 'plg' (self-signup) by default. When staff create the
workspace for a client (e.g. from your admin dashboard), pass origin: "staff": the company gets
no acquisition tag and none of the self-signup handling (signup defaults, duplicate suggestion).
const result = await os.onboard({
externalOrgId: "org_abc123",
label: "My Product",
hints: { companyName: "Acme Inc" },
origin: "staff",
})
Handling Existing Customers
const result = await os.onboard({
externalOrgId: "org_abc123",
label: "My Product",
hints: { companyName: "Acme Inc" },
})
switch (result.linkStatus) {
case "already_linked":
console.log("Welcome back!")
break
case "created":
console.log("New company created!")
break
}
Product Webhooks
When ChatarminOS pushes company and product-link changes to your app, verify the request with the SDK before handling the payload.
import { webhooks } from "@chatarmin/os/webhooks"
export async function POST(request: Request) {
const rawBody = await request.text()
const event = webhooks.constructEvent(
rawBody,
request.headers,
process.env.OS_WEBHOOK_SIGNING_SECRET!,
)
switch (`${event.type}.${event.action}`) {
case "company.updated":
// event.data, event.webhookId, event.idempotency_key
break
}
return Response.json({ received: true })
}
Use ChatarminOS.webhooks.constructEvent if you already import the main client. Dedupe on event.webhookId or event.idempotency_key.
Common Patterns
Feature Gating
async function checkFeatureAccess(orgId: string, feature: string) {
const access = await os.features.checkByExternalOrgId(orgId, feature)
if (!access.isEnabled) {
throw new Error(`Feature ${feature} is not available on your plan`)
}
if (!access.canUse) {
throw new Error(`Usage limit reached for ${feature}. Please upgrade.`)
}
return access
}
// Usage
await checkFeatureAccess("org_abc123", "ai_credit")
await performAIOperation()
await os.billing.trackUsage({
companyId: company.id,
featureCode: "ai_credit",
quantity: 1,
})
Stripe Subscription Flow
// 1. User selects a plan → Get tier prices
const tier = await os.tiers.get("pro")
// 2. Create Stripe Checkout Session with tier prices
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: tier.stripePriceIds.map((price) => ({
price,
quantity: 1,
})),
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/cancel`,
})
// 3. After checkout completes → Claim subscription
const result = await os.onboard({
externalOrgId: "org_abc123",
label: "My Product",
checkoutSessionId: session.id,
})
Backfilling Historical Data
import { ChatarminOS } from "@chatarmin/os"
import historicalData from "./migration-data.json"
const os = new ChatarminOS({ apiKey: process.env.OS_API_KEY! })
for (const org of historicalData) {
// Create company with historical date
const result = await os.onboard({
externalOrgId: org.id,
label: "Imported", // Display label for historical import
hints: {
companyName: org.name,
domain: org.domain,
},
tierCode: org.plan,
contactEmail: org.adminEmail,
createdAt: org.createdAt, // Historical timestamp
})
// Sync contacts
await os.contacts.bulkUpsert({
companyId: result.companyId,
contacts: org.users.map((u) => ({
email: u.email,
name: u.name,
role: u.role,
})),
})
}
TypeScript Support
The SDK is fully typed with comprehensive TypeScript definitions.
Importing Types
import { ChatarminOS } from "@chatarmin/os"
import type {
ChatarminOSConfig,
CreateCompanyInput,
ContactInput,
FeatureCheckInput,
FeatureSetAccessInput,
TrackUsageInput,
OnboardInput,
OnboardResult,
ApplyTierInput,
ClaimCheckoutInput,
LinkSubscriptionInput,
} from "@chatarmin/os"
Router Types
import type { AppRouter } from "@chatarmin/os"
Configuration
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
apiKey | string | ✅ | — | API key from OS admin panel |
baseUrl | string | — | https://os.chatarmin.com/api/v1 | API endpoint |
const os = new ChatarminOS({
apiKey: process.env.OS_API_KEY!,
baseUrl: "https://os.chatarmin.com/api/v1", // Default
})
// Local development
const osLocal = new ChatarminOS({
apiKey: process.env.OS_API_KEY!,
baseUrl: "http://localhost:3000/api/v1",
})
Getting Your API Key
- Go to ChatarminOS → Settings → Developers → API Keys
- Click "Create API Key"
- Copy the key (format:
os_sk_xxxxxxxxxxxx) - Store in environment variables
Error Handling
The SDK uses tRPC under the hood. Errors include structured information:
try {
await os.billing.trackUsage({
companyId: "comp_xxx",
featureCode: "ai_credit",
quantity: 1000,
})
} catch (error) {
if (error instanceof TRPCClientError) {
console.error("Code:", error.data?.code) // e.g., 'FORBIDDEN'
console.error("Message:", error.message) // Human-readable message
console.error("HTTP Status:", error.data?.httpStatus)
}
}
Common Error Codes
| Code | Description |
|---|---|
UNAUTHORIZED | Invalid or missing API key |
FORBIDDEN | No access to this resource |
NOT_FOUND | Company/feature not found |
BAD_REQUEST | Invalid input parameters |
PRECONDITION_FAILED | Usage limit exceeded |
Related Documentation
- Product billing API — Billing read APIs,
externalOrgIdscoping, and preview test tenants - CHANGELOG.md — Release notes. A note under
## Unreleasedreleases itself when it reachesmain(.github/workflows/sdk-release.yml):(major)/(minor)in its heading picks the bump, otherwise a patch - test-sdk.ts — Complete usage examples
Support
- Email: [email protected]
- Documentation: https://os.chatarmin.com/docs
License
MIT © Chatarmin GmbH