Developer documentation
Retainly helps you understand who does what in your product—signups, upgrades, churn signals, and everything in between. Send structured events from your backend so revenue and product teams see the same truth, without exposing secrets in the browser.
The official retainly package is a server-side client for Node 18+, Bun, Deno, Cloudflare Workers, or any runtime with fetch. It has no React import and no browser bundle—your API key stays on the server while you call track and identify from APIs, jobs, and server components.
Overview
In a typical SaaS setup you will: create one client per process, attach stable userId / accountId where you can, and emit events at the moment business outcomes happen (checkout, invoice paid, seat added)—not only on page views.
RETAINLY_API_KEY in your hosting provider’s secrets (or pass it only in server-only code). Never ship it to the browser or commit it to git.Installation
Add the package from npm using your preferred package manager. The same dependency works in Express, Fastify, Hono, Nest, Next.js route handlers, and background workers.
npm install retainly
# or
pnpm add retainly
# or
yarn add retainly
# or
bun add retainlyEverything below is copy-paste ready. Requirements: Node.js 18+ (or any runtime with global fetch). The SDK has no extra runtime npm dependencies beyond what your environment already provides.
Quick start
Minimal flow: instantiate once, then call track after something meaningful happens (subscription, export, invite sent).
import { RetainlyServer } from 'retainly'
const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!)
await retainly.track('subscription_created', {
userId: user.id,
accountId: org.id,
idempotencyKey: stripeEvent.id,
properties: {
plan: 'pro',
amount: 29,
currency: 'USD',
},
})Node (Express / Fastify / Hono / Nest)
Export a single shared client from a small module so every route and service uses the same instance (per process).
import { RetainlyServer } from 'retainly'
export const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!, {
onError(err) {
console.error('[retainly] failed to send event', err)
},
})
// Later, in a route or service:
await retainly.track('user_signed_in', { userId: user.id })RetainlyServer
By default events go to Retainly’s hosted ingest at https://ingest.retainly.us (also available as DEFAULT_ENDPOINT from the package). If you self-host Retainly, set options.endpoint to your ingest base URL.
import { RetainlyServer } from 'retainly'
const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!, {
onError(err) {
console.error('[retainly] failed to send event', err)
},
})track()
await retainly.track(eventName, options?) sends a server event. Common fields: userId, accountId, idempotencyKey (dedupe webhooks and retries), properties, and optional context for request metadata.
await retainly.track('subscription_created', {
userId: user.id,
accountId: org.id,
idempotencyKey: stripeEvent.id,
properties: {
plan: 'pro',
amount: 29,
currency: 'USD',
},
})Example with richer context for API or middleware instrumentation:
await retainly.track('api_request', {
userId: 'user_123',
accountId: 'org_456',
idempotencyKey: 'evt_789',
properties: { path: '/api/projects', method: 'POST' },
context: {
environment: process.env.NODE_ENV,
request: {
requestId: 'req_1',
path: '/api/projects',
method: 'POST',
status: 201,
durationMs: 42,
},
},
})identify()
Call identify when you learn stable traits (email, plan, role) so Retainly can tie events to real people and accounts. This sends a $identify event with userId and traits in properties.
await retainly.identify('user_123', { email: 'a@b.com', plan: 'pro' }, { accountId: 'org_456' })Next.js
Use the same retainly package in Route Handlers, Server Actions, or API routes—always on the server.
Route Handler (App Router)
// app/api/checkout/route.ts
import { RetainlyServer } from 'retainly'
const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!)
export async function POST(req: Request) {
await retainly.track('checkout_started', {
userId: req.headers.get('x-user-id'),
properties: { path: new URL(req.url).pathname },
})
return Response.json({ ok: true })
}Server Action
'use server'
import { RetainlyServer } from 'retainly'
const retainly = new RetainlyServer(process.env.RETAINLY_API_KEY!)
export async function createProjectAction(input: { name: string; userId: string }) {
await retainly.track('project_created', { userId: input.userId, properties: { name: input.name } })
}Middleware (Edge)
Prefer fire-and-forget fetch so you do not block the response. Use buildServerEvent and DEFAULT_ENDPOINT from retainly.
// middleware.ts
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { buildServerEvent, DEFAULT_ENDPOINT } from 'retainly'
export function middleware(req: NextRequest) {
const userId = req.headers.get('x-user-id')
if (userId) {
const event = buildServerEvent('request', {
userId,
properties: { path: req.nextUrl.pathname, method: req.method },
})
fetch(`${DEFAULT_ENDPOINT}/v1/server`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Retainly-Key': process.env.RETAINLY_API_KEY!,
},
body: JSON.stringify(event),
}).catch(() => {})
}
return NextResponse.next()
}React and browsers
Do not put your Retainly API key in client-side React. Send a lightweight request to your own API, then call RetainlyServer from that route.
// React → your backend → RetainlyServer
await fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'button_clicked', properties: { id: 'upgrade' } }),
})Utilities
For custom instrumentation (middleware, proxies, or your own batching), the package exports small primitives:
buildServerEvent— build a server payload before POSTing to ingestmatchesRoute— match paths with string, wildcard, regex, or predicateshouldTrackByMode— shared gating by mode, status, duration, slow thresholduserIdFromHeader,chainUserIdResolvers— resolve a stable user id from incoming requests
Optional constructor options: endpoint (custom ingest base), onError(err, droppedEvents?) when serialization, network, or non-OK responses occur.
Event naming
Use consistent, descriptive names—often snake_case for machine events (e.g. checkout_started, subscription_created)—so funnels and alerts stay readable as you scale.
checkout_startedpayment_failedevt1— too vague