Amba

Node SDK

Server-side quickstart for @layers/amba-node — install, configure, asUser() per-request scoping, and Connect/Express middleware in under 10 minutes.

@layers/amba-node is the Node.js server SDK. Same 25-namespace surface as @layers/amba-web, idiomatic for long-running Node processes — typically one client per service, per-request user scoping via asUser(uid), and a built-in Connect-style middleware factory.

When to use this SDK:

  • Webhook handlers (Stripe, RevenueCat, GitHub) that need to mutate amba state from server context.
  • Background workers / cron jobs that need to call the amba API on behalf of users.
  • BFFs that proxy specific operations from a browser to amba with extra business logic.

1. Install

pnpm add @layers/amba-node
# or: npm i @layers/amba-node

Requires Node 20+ (uses native fetch, top-level await, and import.meta.resolve).

2. Configure once at boot

AmbaClient.configure() is a one-time async call that initializes inside the Node process and returns the configured client. Hold the returned instance for the life of the process.

// src/amba.ts
import { AmbaClient } from '@layers/amba-node';
 
export const amba = await AmbaClient.configure({
  apiKey: process.env.AMBA_API_KEY!,
  // Optional:
  // baseUrl: 'https://api.amba.dev', // default
  // consentRequired: false,
  // debug: process.env.NODE_ENV !== 'production',
});

Set AMBA_API_KEY=amb_dev_ck_XXXX in your environment (your usual env-var management — secret store, container env, CI variable, etc.). Use a server-only key — the dev key is fine for development, but production deployments should mint a dedicated server key via the console.

3. Per-request user scoping with asUser()

The server endpoints under /v1/client/* all run under clientSessionAuth — they require a Bearer session token for an app_user. The Node SDK's asUser(uid) is how you mint that scope: given an app_user.id that already exists in your project (typically created server-side when your auth flow signs in a real end-user), asUser(uid) returns a handle whose subsequent calls all act as that user.

// src/routes/orders.ts
import express from 'express';
import { amba } from '../amba.js';
 
const router = express.Router();
 
router.post('/orders', async (req, res) => {
  const userId = req.session.userId; // however your app resolves end-user identity
  const scoped = amba.asUser(userId);
 
  // All these calls now run as `userId` — server-side auto-scoped scopes
  // collection reads/writes, and events.track attributes to that user.
  const order = await scoped.collections.insert('orders', {
    items: req.body.items,
    total: req.body.total,
  });
 
  await scoped.events.track('order_created', { order_id: order.id });
 
  res.json({ order });
});

What if there are no app_users yet? Brand-new tenants (no end-users signed in) can mint an anonymous session via await amba.auth.signInAnonymously() — the resulting app_user.id then becomes a valid argument to asUser(). For greenfield apps without their own auth flow, that's the canonical "first user" path.

Without asUser() or signInAnonymously(), the Node SDK has no session token and every /v1/client/* call returns 401 Unauthorized — session token missing or expired. The SDK is not implicitly server-trusted on the client endpoints; you choose which user it acts as on every request.

4. First track (post-auth)

Now that the SDK is scoped to a user, track an event:

// src/routes/webhook-stripe.ts
import { amba } from '../amba.js';
 
export async function handleStripeWebhook(event: Stripe.Event) {
  if (event.type === 'checkout.session.completed') {
    // Resolve the app_user from the webhook payload — your business logic
    // determines how a Stripe session maps to an amba app_user.
    const userId = await resolveAmbaUserFromStripeSession(event.data.object);
 
    await amba.asUser(userId).events.track('checkout_completed', {
      session_id: event.id,
      amount: event.data.object.amount_total,
    });
  }
}

If your webhook handler doesn't have a specific user in scope, mint an anonymous session first:

const session = await amba.auth.signInAnonymously();
await amba.events.track('webhook_received', { source: 'stripe' });

asUser(uid) returns a handle that:

  1. Wraps every read with the equivalent of WHERE user_id = uid.
  2. Rejects insert / update payloads whose user_id differs from uid.
  3. On delete, restricts to rows where user_id = uid.

Without asUser(), the Node SDK is server-trusted — it can read and write any row across any user. Use that intentionally for admin operations; otherwise scope every request.

5. Connect/Express middleware

For request-handlers that always need the SDK, amba.middleware() returns a Connect-style factory that attaches req.amba to every incoming request:

import express from 'express';
import { amba } from './amba.js';
 
const app = express();
app.use(express.json());
app.use(amba.middleware());
 
app.post('/webhook', (req, res) => {
  req.amba.events.track('webhook_received', { source: 'stripe' });
  res.sendStatus(204);
});
 
app.listen(3000);

For a per-request user-scoped handle, chain req.amba.asUser(req.session.userId) inside each handler.

Collections

Same DSL as the web SDK:

const { where } = amba.collections;
 
const { data: orders } = await amba.collections.find('orders', {
  filter: where.eq('status', 'paid'),
  limit: 100,
});
 
const order = await amba.collections.findOne('orders', 'ord_123');
 
await amba.collections.update('orders', 'ord_123', { status: 'refunded' });
await amba.collections.delete('orders', 'ord_123'); // soft delete

For full operator coverage (eq, ne, gt, gte, lt, lte, in, notIn, like, ilike, isNull, isNotNull, and, or, not) see collections.

Storage (presign + commit)

The Node SDK exposes presign and commit directly so the upload itself can flow through your server — useful for buffered uploads, validation, or virus scanning:

const presign = await amba.storage.presign({
  bucket: 'invoices',
  filename: `invoice-${order.id}.pdf`,
  mimeType: 'application/pdf',
  sizeBytes: pdfBuffer.byteLength,
  retentionDays: 365,
});
 
await fetch(presign.upload_url, {
  method: 'PUT',
  headers: Object.fromEntries(presign.upload_headers),
  body: pdfBuffer,
});
 
const asset = await amba.storage.commit(presign.upload_id, presign.asset_id);
console.log(asset.url);

Push fan-out

If your service is the source of truth for "send a push to user X", call register() once when the device hands you a token, and use the push campaign API (admin) to fan out from your server:

await amba.asUser(userId).push.register(deviceToken, 'apns', 'com.acme.app');

AI proxy

const reply = await amba.ai.anthropic.messages.create({
  prompt_slug: 'order_summary',
  variables: { order_id: order.id, items: order.items },
  max_tokens: 512,
});

Same prompt slug catalog as the client SDK — slugs are managed in the console and shared across server + browser usage.

Long-running processes

The Node SDK is safe for long-lived processes:

  • The underlying amba core caches HTTP connections.
  • Auth token refresh is automatic.
  • There's no global state — multiple AmbaClient instances (e.g. one per tenant key in a multi-tenant proxy) are isolated.

For high-concurrency scenarios (10k+ rps), pool one AmbaClient per worker process rather than per request.

Common pitfalls

  • Calling AmbaClient.configure() per request — don't. The cold-start init is ~50ms; call once at boot, share the instance.
  • Forgetting asUser(uid) in user-context handlers — the default surface is server-trusted; without asUser you can accidentally read or mutate another user's data. Lint rule: every route handler that takes user input should explicitly choose amba.asUser(uid) or amba (admin).
  • WebSocket / SSE proxies — auth state isn't shared with the browser; mint a short-lived session in the browser instead of proxying through Node.

See also

On this page