Version: 0.2.0 · Last updated: 2026-07-28
Metered API platform: per-endpoint pricing, per-tier multipliers, usage ledger, Stripe metered billing, purchase-order invoicing, a company admin console, a customer portal with TOTP 2FA, and a Zapier integration. This document is the full technical reference. For operations, see USER-MANUAL.md.
Stack: Node 20 · TypeScript (strict) · Express 4 · better-sqlite3 · express-openapi-validator · swagger-ui-express · stripe SDK · Zapier Platform (core ^19) · jest + supertest (root) and mocha (zapier-app).
┌────────────────────────────────────────────┐
│ Express app │
│ (src/app.ts) │
│ │
Browser ── /admin ──────┼── static admin UI (admin/) │
Browser ── /admin/api ──┼── adminAuth → adminRouter (src/admin.ts) │
│ pricing · customers · invoices · │
│ reports · zapier status │
Browser ── /portal ─────┼── static portal SPA (portal/) │
Browser ── /portal/api ─┼── portalRouter (src/portal.ts) │
│ signup/login+2FA · me · usage · │
│ invoices · reload · prefs │
Browser ── /docs ───────┼── swagger-ui (openapi.yaml) │
│ │
Client ──── /v1/* ──────┼── apiKeyAuth (src/auth.ts) │
│ └─ OpenAPI request validation │
│ └─ meter() (src/meter.ts) │
│ ├─ quoteCall() (src/pricing.ts) │
│ └─ UsageRepo.record() │
└──────────────┬─────────────────────────────┘
│
┌───────────────┬───────────────┼───────────────┬────────────┐
│ │ │ │ │
SqliteUsageRepo SqliteCustomerRepo SqlitePricingStore SqliteInvoiceRepo
(usage_entries) (customers) (price_endpoints, (invoices,
│ │ tiers) invoice_lines)
│ SqliteSessionRepo (portal_sessions) │
└───────────────┴───────────────┴───────────────┴────────────┘
│ zappier.db (SQLite)
Cron (daily 06:17 ET) │
"Zappier billing · report usage" │
│ │
└─► src/jobs/report-usage.ts ── reportMonthlyUsage()
├─ computeBillableCents / computeDelta (src/billing/stripe.ts)
├─ SqliteBillingReportRepo (billing_reports, job_locks)
└─ stripe.billing.meterEvents.create ──► Stripe
Design rules the codebase follows:
UsageRepo,
CustomerRepo, PricingStore,
MeterEventClient are interfaces with in-memory adapters
(tests) and SQLite / Stripe adapters (production). Nothing outside
src/db/ and the job’s main() touches SQL or
the Stripe SDK.PricingContext
getters in buildApp read the store on every quote, so admin
edits apply without a restart.Math.round).| File | Responsibility | Key exports |
|---|---|---|
src/index.ts |
Production entry: opens SQLite, wires repos, listens | — |
src/app.ts |
buildApp(deps) — full Express wiring;
StoredItem; DEFAULT_CUSTOMERS |
buildApp, AppDeps |
src/auth.ts |
Customer model + x-api-key middleware |
Customer, CustomerRepo,
apiKeyAuth, InMemoryCustomerRepo |
src/pricing.ts |
Pricing domain: rules, tiers, quote algorithm, store port | PriceRule, TierConfig, Quote,
quoteCall, PricingStore,
DEFAULT_RATE_CARD, DEFAULT_TIERS |
src/meter.ts |
Per-request metering middleware | meter(endpointId, repo, pricing) |
src/usage.ts |
Usage ledger domain + summaries | UsageEntry, UsageSummary,
summarize, UsageRepo,
InMemoryUsageRepo |
src/admin.ts |
Admin API (/admin/api) + admin-key guard |
adminAuth, adminRouter |
src/billing/credit.ts |
Monthly credit application | applyMonthlyCredit, BilledSummary |
src/billing/stripe.ts |
Stripe-facing math + client port | METER_EVENT_NAME, MeterEventClient,
computeBillableCents, computeDelta,
reportUsage |
src/billing/reload.ts |
Portal reloads via Stripe PaymentIntents | stripePaymentClient, hasRealStripeKey |
src/accounts.ts |
Portal identity: scrypt passwords, RFC 6238 TOTP, sessions | hashPassword, verifyPassword,
totp, verifyTotp,
generateTotpSecret, totpUri,
SessionRepo, InMemorySessionRepo |
src/invoicing.ts |
Invoice domain + generation | Invoice, InvoiceLine,
buildInvoice, InvoiceRepo,
InMemoryInvoiceRepo |
src/reports.ts |
Billing/usage aggregation + CSV | billingRows, usageTrend,
toCsv, BillingRow,
TrendPoint |
src/portal.ts |
Customer portal API (/portal/api) |
portalRouter, PortalDeps,
PaymentClient, PaymentResult |
src/paths.ts |
Installation-root resolution (cwd-independent) | PROJECT_ROOT |
src/jobs/report-usage.ts |
Billing job: delta reporting to Stripe | reportMonthlyUsage, firstOfMonthUtc,
ReportUsageDeps |
src/db/usage-repo.ts |
SQLite adapter: usage_entries |
SqliteUsageRepo |
src/db/customer-repo.ts |
SQLite adapter: customers (seeds when empty; idempotent
column migrations) |
SqliteCustomerRepo |
src/db/pricing-store.ts |
SQLite adapter: price_endpoints, tiers
(seeds when empty) |
SqlitePricingStore |
src/db/billing-repo.ts |
SQLite adapter: billing_reports,
job_locks |
SqliteBillingReportRepo,
BillingReportRepo, JobLockRepo |
src/db/invoice-repo.ts |
SQLite adapter: invoices,
invoice_lines |
SqliteInvoiceRepo |
src/db/session-repo.ts |
SQLite adapter: portal_sessions |
SqliteSessionRepo |
openapi.yaml |
Public API contract; drives validation and /docs |
— |
admin/ |
Dependency-free admin SPA (index.html,
app.js) |
— |
portal/ |
Dependency-free customer portal SPA (index.html,
app.js) |
— |
src/pricing.ts)type PriceRule =
| { kind: 'free' }
| { kind: 'fixed'; fixedCents: number }
| { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number };
interface TierConfig {
id: string; name: string;
multiplier: number; // e.g. 0.5 = 50% of list
monthlyCreditCents: number; // free included usage per month
defaultRule?: PriceRule; // fallback for endpoints with no rule
}
interface Quote {
endpointId: string;
listCents: number; // before multiplier
totalCents: number; // after multiplier — this is what is recorded
breakdown: { baseCents: number; metadataCents: number; attachmentCents: number };
}quoteCall(pricing, tierId, endpointId, usage, multiplierOverride?)Unknown tier: <id> (mapped
to 403 by meter).endpointId, else the
tier’s defaultRule, else throw
No price rule for <tier>/<endpoint> (403).free → all zeros.fixed → baseCents = fixedCents.variable → baseCents +
perKbCents × ceil(metadataBytes/1024) +
perMbCents × ceil(attachmentBytes/1048576). Note the
ceiling: 1 byte of metadata bills a full KB unit; attachments bill per
started MB.listCents = sum of breakdown;
multiplier = multiplierOverride ?? tier.multiplier;
totalCents = Math.round(listCents × multiplier).totalCents (never listCents) is what the
usage ledger records and what the billing pipeline sums.
For POST /v1/storage:
express.json() parses JSON bodies (multipart handled by
the validator’s multer — 25 MB per-file cap).apiKeyAuth (src/auth.ts) —
x-api-key → Customer on
req.customer, else 401.express-openapi-validator checks the request against
openapi.yaml (400 on violation). For
/v1/storage, parseMetadata then JSON-parses
the metadata form field into
res.locals.parsedMetadata (400 on bad JSON).meter('storage', usage, pricing)
(src/meter.ts):
metadataBytes (UTF-8 length of the
JSON-stringified metadata) and attachmentBytes (sum of
multer file sizes),quoteCall — pricing errors become 403,UsageEntry with
cents = quote.totalCents,res.locals.quote.StoredItem (in-memory
list) and responds { id, quote }.GET /v1/usage is not metered; it
summarizes the caller’s month-to-date usage and applies the tier credit
via applyMonthlyCredit.
Single database file (ZAPPIER_DB, default
zappier.db), WAL-agnostic better-sqlite3, all tables
created with CREATE TABLE IF NOT EXISTS in the repo
constructors. Seeding rule: customers and
pricing tables seed from DEFAULT_CUSTOMERS /
DEFAULT_RATE_CARD / DEFAULT_TIERS only when
empty.
| Table | Columns | Written by |
|---|---|---|
usage_entries |
id, customer_id, endpoint_id,
cents, metadata_bytes,
attachment_bytes, timestamp_ms |
meter() on every priced call |
customers |
id PK, name, tier_id,
api_key UNIQUE, stripe_customer_id,
multiplier_override, billing_type,
email, password_hash,
totp_secret, totp_enabled,
balance_cents, email_invoicing |
Admin API, portal API |
price_endpoints |
endpoint_id PK, rule_json |
Admin API |
tiers |
id PK, name, multiplier,
monthly_credit_cents, default_rule_json |
Admin API |
invoices |
id PK, customer_id, period,
status, cents totals, billing_type,
po_number, lifecycle timestamps |
Admin API (generate/issue/paid + balance drawdown) |
invoice_lines |
invoice_id, endpoint_id,
calls, cents |
Invoice generation |
portal_sessions |
token PK, customer_id,
created_ms, expires_ms |
Portal auth |
billing_reports |
customer_id + period PK,
reported_cents (cumulative),
reported_at_ms |
Billing job, after each successful meter event |
job_locks |
name PK, acquired_at_ms |
Billing job run guard |
New customer columns are added by idempotent
migrations (PRAGMA table_info guard +
ALTER TABLE ADD COLUMN) when the repo opens an older
database — no manual migration step.
In tests, every repo is constructed over :memory:
databases.
src/billing/stripe.ts)computeBillableCents(entries, monthlyCreditCents)
= max(0, Σ entry.cents − monthlyCreditCents)
computeDelta(billable, previouslyReported)
= max(0, billable − previouslyReported)reportUsage(client, stripeCustomerId, entries, monthlyCreditCents)
is the original whole-month reporter — retained as public
API; the job uses the delta path instead.
src/jobs/report-usage.ts)reportMonthlyUsage(deps) per run:
locks.tryAcquireLock('report-usage', 1h TTL) — a single
atomic
INSERT … ON CONFLICT … DO UPDATE … WHERE acquired_at_ms <= now − ttl.
Failure aborts the run; release happens in finally. A
crashed run’s lock is taken over after the TTL.since = firstOfMonthUtc(now)
(injectable via deps.since for tests);
period = since.toISOString().slice(0, 7)
(YYYY-MM).stripeCustomerId (others skipped
silently; unknown tierId warns and skips):
entries = usage.listFor(customer.id, since)billable = computeBillableCents(entries, tier.monthlyCreditCents)prior = billingRepo.getReportedCents(customer.id, period)
(0 for a new month — periods are isolated by the composite PK)delta = computeDelta(billable, prior);
delta <= 0 → log skip, continuecreateMeterEvent({ eventName: 'zappier.api_cents', customerId: stripeCustomerId, value: String(delta), identifier:stripeCustomerId:{period}:${billable}})upsertReportedCents(customer.id, period, billable) —
cumulative, not the delta.Idempotency guarantees (reviewed design):
identifier
(uniqueness enforced within a rolling 24 h window).CLI: npx ts-node src/jobs/report-usage.ts (guarded by
require.main === module; loads .env via dotenv
inside main()). Scheduled by the Kimi cron job “Zappier
billing · report usage to Stripe” (17 6 * * *,
America/New_York), which runs this command daily and reports the
outcome.
/v1,
auth: x-api-key)Defined in openapi.yaml; interactive docs at
/docs.
| Operation | Method & path | Price | Notes |
|---|---|---|---|
status |
GET /v1/status |
free | Health + quote echo |
transform |
POST /v1/transform |
fixed | {text} → {output: TEXT, quote} |
storage |
POST /v1/storage |
variable | multipart: metadata (JSON string),
attachments[] (≤25 MB/file) → {id, quote} |
storage-list |
GET /v1/storage |
free | Caller’s stored items |
usage |
GET /v1/usage |
unmetered | Month-to-date summary with credit applied |
Error envelope: { "error": string } with 400
(validation/metadata), 401 (bad key), 403 (unknown tier / no price
rule), 413 (file over 25 MB).
/admin/api, auth: x-admin-key or login
session)| Route | Purpose |
|---|---|
POST /login |
{username, password} → session token (accounts:
ADMIN_USER/ADMIN_KEY,
DEMO_ADMIN_USER/DEMO_ADMIN_PASSWORD) |
GET /pricing |
{ rateCard, tiers } |
PUT /endpoints/:id |
Upsert a PriceRule (validated: 400 on bad shape) |
DELETE /endpoints/:id |
Remove a rule (endpoint becomes 403 for tiers without a default rule) |
PUT /tiers/:id |
Upsert a TierConfig |
DELETE /tiers/:id |
Remove a tier |
GET /customers |
List customers without API keys |
POST /customers |
Create {name, tierId} → full customer incl. generated
apiKey (201, shown once) |
PUT /customers/:id |
Patch name / tierId /
multiplierOverride / stripeCustomerId /
billingType / email |
POST /invoices/generate |
{period, customerId?, poNumber?} — drafts per customer
with usage; regenerating replaces drafts, skips issued/paid →
{generated, skipped} |
GET /invoices |
Filters: customerId, period,
status |
GET /invoices/:id |
JSON, or print-ready HTML with ?format=html |
POST /invoices/:id/issue |
draft → issued (PO gets 30-day due date). Prepaid
drawdown: if the customer’s balanceCents fully
covers billableCents, the balance is deducted and the
invoice is saved as paid instead |
POST /invoices/:id/paid |
issued → paid |
GET /reports/billing |
from/to/customerId/billingType
filters; JSON rows or format=csv |
GET /reports/usage-trend |
bucket=day\|week, same range filters |
GET /zapier/status |
Zapier app dir presence, version, triggers, creates |
/portal/api, auth: Bearer session)| Route | Purpose |
|---|---|
POST /signup |
{name, email, password≥8} → 201
{token, customer}. New customer on free with
instant API key; an email match on a passwordless (admin-created)
customer claims that account; 409 when the email
already has a password |
POST /login |
{email, password, totpCode?} →
{token, customer}; 401 totp_required when 2FA
is on and the code is missing/wrong |
POST /logout |
Deletes the session |
GET /me |
Public profile — never includes
passwordHash/totpSecret |
POST /api-key |
Regenerates the API key (old key dies immediately) |
GET /usage |
Month-to-date summary with tier credit applied |
GET /pricing |
Live rate card + tiers for the pricing page |
GET /invoices · GET /invoices/:id |
Own invoices only (others 404); ?format=html print
view |
POST /2fa/setup |
Generates + stores a TOTP secret (not yet enabled) →
{secret, uri, qr} (QR as PNG data URL via
qrcode) |
POST /2fa/enable · POST /2fa/disable |
{code} verified against the stored secret |
POST /reload |
{amountCents} integer $1–$10,000 via the injected
PaymentClient — dev client credits instantly; Stripe client
returns a clientSecret and credits on confirmation |
PUT /email-invoicing |
{enabled} preference |
Sessions live in portal_sessions (7-day TTL) and survive
restarts. src/accounts.ts implements scrypt hashing
(scrypt:N:r:p:salt:hash, timing-safe compare) and RFC 6238
TOTP (HMAC-SHA1, 30 s step, 6 digits, ±1 step window) with no external
crypto dependency.
zapier-app/ — Zapier Platform (core ^19), CommonJS,
mocha tests.
| File | Purpose |
|---|---|
index.js |
App definition; wires auth, trigger, action |
authentication.js |
API-key auth; test call against /v1/status |
triggers/new_item.js |
Polling trigger: GET /v1/storage, newest first, dedupe
by id |
creates/store_data.js |
Action: multipart POST /v1/storage (form-data), fields:
metadata JSON + optional files |
test/ |
mocha suite (4 tests): auth, trigger, action |
Publish flow: zapier login → zapier push →
invite users / submit for review. The app’s base URL must point at a
publicly reachable deployment of the API server.
npm test # jest, repo root — 165 tests / 22 suites
npx tsc --noEmit # type gate
cd zapier-app && npm test # mocha — 4 testsConventions:
buildApp() with in-memory repos.:memory: databases.MeterEventClient; the
idempotency suite (tests/report-usage-idempotency.test.ts)
simulates growth, re-runs, month rollover, Stripe throws, lock
contention, and partial failure.reportMonthlyUsage(deps) — never by executing
main().| Env var | Default | Used by |
|---|---|---|
PORT |
3000 |
src/index.ts |
ZAPPIER_DB |
<root>/zappier.db |
src/index.ts, billing job |
ADMIN_KEY |
admin-dev-key |
adminAuth() |
ADMIN_USER |
admin |
Admin login |
DEMO_ADMIN_USER / DEMO_ADMIN_PASSWORD |
demo / $$$Adm1n### |
Demo admin login |
STRIPE_SECRET_KEY |
— | Billing job; portal reloads when it starts with sk_
(otherwise a dev payment client credits instantly) |
Both src/index.ts and the billing job load
.env from the installation root (src/paths.ts
PROJECT_ROOT) — never from the process cwd — so the
compiled server and the job run from any working directory.
.env is gitignored (chmod 600);
.env.example documents the shape. Git identity is
configured repo-local; .gitignore covers
node_modules/, dist/, .env,
zappier.db*.
Add an API endpoint: 1. Add the path +
operationId to openapi.yaml (validation &
docs follow automatically). 2. Add the route in src/app.ts,
wrapping the handler with
meter('<operationId>', usage, pricing). 3. Add a
rate-card rule (admin UI or
PUT /admin/api/endpoints/<operationId>) — otherwise
tiers without a defaultRule get 403. 4. Write the failing
test first; keep npm test + tsc green.
Add a customer type: admin UI or
PUT /admin/api/tiers/:id
({name, multiplier, monthlyCreditCents, defaultRule?}).
Swap the storage backend: implement
UsageRepo / CustomerRepo /
PricingStore against your database and pass them to
buildApp({...}) — no other code changes. Same for
BillingReportRepo/JobLockRepo in the job.
Change the billing cadence: the job is safe at any frequency (delta + ledger + lock). The Kimi cron job controls scheduling; update its cron expression to change cadence.
Known intentional limitations: stored items are
in-memory (restart clears them; usage ledger is unaffected);
reportUsage is retained but superseded by the delta path;
Stripe identifier dedup covers a rolling 24 h window;
releaseLock is not owner-scoped (harmless at this job’s
runtime); admin session tokens are in-memory (portal sessions are
persisted); Stripe reloads credit the balance only after payment
confirmation (no webhook endpoint yet — dev client credits instantly);
email invoicing stores the preference but sending requires SMTP wiring
(deferred); PO invoices with partial prepaid coverage are not partially
paid by design.