Sign in →
1 min read

Node.js SDK

Integrate Aforo metering into Express, Fastify, and Koa applications.

Updated 2026-11-11Suggest edits
Get the Node.js SDK
The Node.js SDK lives in the public repo github.com/aforoai/SDKs under aforo-metering-sdks/node.
View on GitHub Download ZIPgit clone https://github.com/aforoai/SDKs.git

Install & Quickstart#

Add Aforo metering to your Node.js service. Express, Fastify, and Koa middleware plus a manual track() client.

@aforoai/metering 2.0.0 requires Node 18+ (it uses the built-in fetch). Events POST to https://api.aforo.ai/v1/ingest/batch with the key in X-API-Key. baseUrl must be https:// (plain http only for localhost, or with allowInsecureHttp: true).

terminal
npm i @aforoai/metering

Manual Tracking

usage.js
import { AforoClient } from '@aforoai/metering';

const aforo = new AforoClient({
  apiKey: process.env.AFORO_API_KEY,
  productType: 'API', // default; sent as top-level productType on every event
});

// Count one billable action — queued, sent in the background. Never throws.
await aforo.track({
  customerId: 'cust_123',
  metricName: 'api_calls',
  quantity: 1,
});

// The SDK installs no signal handlers. Flush from your own shutdown path:
process.once('SIGTERM', async () => {
  await aforo.shutdown(); // sends what is queued, for at most shutdownTimeoutMs (5 s)
  process.exit(0);
});

Express Middleware

app.js
import { AforoClient } from '@aforoai/metering';
import { expressMiddleware } from '@aforoai/metering/middleware/express';

const aforo = new AforoClient({ apiKey: process.env.AFORO_API_KEY, productType: 'API' });

// Meters every request after the response is sent — zero added latency
app.use(expressMiddleware({
  client: aforo,                                          // your client: you flush and shut it down
  customerResolver: (req) => req.user?.aforoCustomerId,   // required: identity your app authenticated
  metricName: 'api_calls',                                // or (req, res) => string; must exist in your catalog
}));

The middleware hooks res.on('finish'), so it runs after the response. The default metric is api_calls, and it must exist in your catalog — the ingestor rejects events for an unknown metric. Creating the middleware without customerResolver throws, unless you opt in to trustCustomerHeader: true (reads X-Customer-Id — only safe when a proxy you control sets that header and strips any client copy). A request whose resolver returns nothing is served normally; its event is dropped as invalid and counted. OPTIONS requests are not metered. Fastify and Koa adapters (fastifyPlugin, koaMiddleware) live under the same @aforoai/metering/middleware/* path and take the same options.

Who Is Billed#

Every integration that meters an incoming request — HTTP middleware, the gRPC / GraphQL / WebSocket / MQTT clients, the MCP SDKs and the MCP proxy — needs to know which customer to bill, and from 2.0.0 you tell it. Pass a customer resolver that reads identity your service already verified (a session, a JWT subject, an API-key lookup, an mTLS identity).

  • Reading a value the caller of your API controls — the X-Customer-Id header, gRPC metadata, MCP _meta.customer_id, the MQTT username — needs an explicit opt-in: trustCustomerHeader (Node, Java), trust_customer_header (Python), TrustCustomerHeader (Go). Turn it on only when a proxy you control sets the value and strips any client copy.
  • With neither a resolver nor the opt-in, construction fails (Node throws, Python raises ValueError, Go returns an error, Spring fails to start). There is no fallback to unknown, anonymous or the agent id.
  • When the resolver returns nothing for one request, the request runs normally and its event is dropped as invalid and counted.
  • A manual track() call is unchanged: you pass customerId yourself.

Delivery & Dropped Events#

All 2.0.0 SDKs send the same way. track() only queues (bounded, 10,000 events by default; on overflow the oldest is evicted). One background sender per client sends one request at a time: every 5 s, or as soon as 500 events are waiting (the batch size, capped at 1,000). Retries on network errors, timeouts, 408, 429 and 5xx use full-jitter backoff and honour Retry-After (seconds or an HTTP date, capped at 60 s).

An outage does not lose usage. After the last retry — or on 401 / 403, which usually means a wrong or revoked key and is logged as an error — the batch goes back to the front of the queue with the same idempotency keys and a breaker pauses sending (5 s, doubling to 5 min). New events keep queueing; the first successful request resumes sending, and the ingestor deduplicates the re-sent keys. Usage is lost only if the queue overflows first, so raise the queue size if you expect long outages. A 413 splits the batch in half.

Shutdown is bounded: shutdown() / close() keeps sending for at most the shutdown timeout (5 s by default) and reports what is still queued. The libraries install no signal handlers — call shutdown from your own SIGTERM path. Python also flushes at atexit, Node on beforeExit, Java in Spring's stop phase.

Drop reasonWhen
overflowThe queue was full (usually during a long outage); the oldest event was evicted.
rejectedThe ingestor refused the batch with a non-retryable 4xx, refused individual events inside an accepted batch, or answered 413 to a single event. Unknown metric names land here.
invalidThe event failed a client-side check before it was queued (blank customer or metric, quantity <= 0, a field over the ingestor limit), or a customer resolver returned nothing.
shutdownStill queued when the shutdown timeout ran out.
closedtrack() was called after shutdown. It does not throw (Go returns ErrClientClosed).
retry_exhaustedKept for API compatibility only — an outage requeues instead.

Every dropped event is counted, logged, and passed to the opt-in onDrop(events, reason) hook (on_drop in Python, OnDrop in Go). Events keep their idempotency keys, so re-tracking them after recovery does not bill twice.

See also
Aforo Documentation — Aforo Docs