@igeaman-org/referrals
v0.1.1
Published
Database-agnostic referral attribution, rewards, fraud controls, and analytics
Maintainers
Readme
@igeaman-org/referrals
Database-agnostic referral infrastructure for TypeScript applications. Track first- and last-touch attribution, issue referral codes, record custom conversion events, calculate rewards, enforce campaign limits, add fraud policies, and query analytics while keeping all data in your own database.
PostgreSQL and Supabase are officially supported. Other databases can be integrated through the ReferralStorage interface.
Features
- First-touch and last-touch attribution, configurable per campaign
- Custom attribution and visitor-cookie windows
- Cryptographically secure referral codes and referral URLs
- Custom conversion events with idempotency
- Pluggable rewards and commissions
- Pluggable fraud evaluation
- Code expiration and atomic usage limits
- Privacy-preserving IP and user-agent hashing
- PostgreSQL transactions, indexes, RLS, and touch-event partitioning
- Browser, server, edge-runtime, and testing entry points
Requirements
- Node.js 20 or later for the PostgreSQL adapter
- PostgreSQL 14 or later, including Supabase PostgreSQL
- Your own server endpoint for accepting browser tracking requests
Do not connect a browser directly to the database or expose a Supabase service-role key.
Installation
pnpm add @igeaman-org/referrals pgEquivalent npm command:
npm install @igeaman-org/referrals pgThe pg dependency is only needed when using the official PostgreSQL/Supabase adapter.
1. Install the database schema
The SQL migration is included in the npm package:
psql "$DATABASE_URL" \
-f node_modules/@igeaman-org/referrals/migrations/postgres.sqlFor Supabase, open the SQL editor, paste the contents of migrations/postgres.sql, and run it once. The migration creates the referrals schema and enables row-level security without granting browser write access.
2. Create a campaign
Campaign settings live in your database:
INSERT INTO referrals.campaigns (
key,
name,
attribution_model,
attribution_window_seconds,
cookie_window_seconds,
code_length,
max_uses_per_code
)
VALUES (
'customer-referrals',
'Customer referrals',
'first_touch',
2592000,
2592000,
10,
NULL
);attribution_model:first_touchorlast_touchattribution_window_seconds: maximum time between the credited touch and conversion;NULLmeans unlimitedcookie_window_seconds: maximum age of a touch when a visitor is identified;NULLmeans unlimitedmax_uses_per_code: campaign default usage limit;NULLmeans unlimited
Both first and last touches are stored regardless of the selected credit model. The model can therefore be changed per campaign without losing attribution history.
3. Configure the server engine
import { Pool } from 'pg';
import { ReferralEngine } from '@igeaman-org/referrals';
import { PostgresReferralStorage } from '@igeaman-org/referrals/postgres';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 30_000,
});
const storage = new PostgresReferralStorage({
pool,
statementTimeoutMs: 5_000,
});
export const referrals = new ReferralEngine({
storage,
hashSecret: process.env.REFERRAL_HASH_SECRET!,
rewardCalculator: ({ conversion, referrerId }) => {
if (conversion.event !== 'purchase' || conversion.value === undefined) {
return [];
}
return [
{
beneficiaryId: referrerId,
amount: conversion.value * 0.1,
currency: conversion.currency ?? 'USD',
},
];
},
});Validate environment variables when the process starts. REFERRAL_HASH_SECRET must be an independently generated secret of at least 32 characters.
4. Create a referral code and URL
Create codes only from an authenticated server route. Derive the referrer ID from the authenticated session rather than accepting it from the request body.
import { buildReferralUrl } from '@igeaman-org/referrals';
const referral = await referrals.createCode({
campaignKey: 'customer-referrals',
referrerId: authenticatedUser.id,
expiresAt: new Date('2027-01-01T00:00:00.000Z'),
maxUses: 100,
});
const referralUrl = buildReferralUrl('https://product.example/join', referral.code);To use a custom code:
await referrals.createCode({
campaignKey: 'customer-referrals',
referrerId: authenticatedUser.id,
code: 'AMAN2026',
});5. Capture a referral touch
Your public server endpoint should validate the request, rate-limit it, obtain the IP address from trusted proxy configuration, and call:
const touch = await referrals.trackTouch({
campaignKey: body.campaignKey,
code: body.code,
visitorId: body.visitorId,
landingUrl: body.landingUrl,
referrerUrl: body.referrerUrl,
ipAddress: trustedClientIp,
userAgent: request.headers.get('user-agent') ?? undefined,
});The engine hashes IP and user-agent values before persistence. Do not add raw personal data to metadata.
Browser client
import { ReferralBrowserClient } from '@igeaman-org/referrals/browser';
const referralClient = new ReferralBrowserClient({
endpoint: 'https://api.product.example/referrals',
campaignKey: 'customer-referrals',
requestTimeoutMs: 5_000,
});
await referralClient.capture();capture() reads the ref query parameter, creates an opaque visitor UUID cookie, and sends the touch to POST {endpoint}/touches. It returns false when the current URL has no referral code.
6. Identify the visitor
After signup or login, associate the anonymous visitor with your authenticated user:
await referrals.identify({
campaignKey: 'customer-referrals',
visitorId: body.visitorId,
subjectId: authenticatedUser.id,
});The server must derive subjectId from its authenticated session. Do not trust a client-provided user ID.
The browser helper sends its visitor ID to POST {endpoint}/identify:
await referralClient.identify(authenticatedUser.id);Your server should ignore or verify the supplied subject ID and use its authenticated identity as the authority.
7. Record a conversion
Conversions can represent any business event: signup, subscription, purchase, activation, booking, or a domain-specific event.
const result = await referrals.recordConversion({
campaignKey: 'customer-referrals',
subjectId: customer.id,
event: 'purchase',
idempotencyKey: `order:${order.id}`,
value: order.total,
currency: order.currency,
metadata: { orderId: order.id },
});
console.log(result.conversion);
console.log(result.rewards);Call this from a trusted server-side business workflow. Reusing the same idempotency key returns the existing conversion and prevents duplicate rewards.
8. Add fraud policies
Fraud evaluators execute before touches, identity binding, or conversions are persisted:
const referrals = new ReferralEngine({
storage,
hashSecret: process.env.REFERRAL_HASH_SECRET!,
fraudEvaluators: [
async (context) => {
if (context.operation === 'conversion' && context.referrerId === context.subjectId) {
return { allow: false, reason: 'Self-referral is not allowed.' };
}
return { allow: true };
},
],
});Applications should additionally check account age, payment instruments, refund history, IP velocity, geographic anomalies, and repeated identities. Rewards are created as pending; approve them after the applicable refund or chargeback period.
9. Query analytics
const summary = await referrals.getAnalytics(
'customer-referrals',
new Date('2026-07-01T00:00:00.000Z'),
new Date('2026-08-01T00:00:00.000Z'),
);The result includes touches, unique visitors, conversions, attributed conversions, conversion value, rewards, and reward amount. Date ranges are half-open: from is inclusive and to is exclusive.
Custom databases
Implement the exported ReferralStorage interface and pass the adapter to ReferralEngine:
import type { ReferralStorage } from '@igeaman-org/referrals';
class ApplicationReferralStorage implements ReferralStorage {
// Implement every method using your database and transaction primitives.
}Your adapter must enforce code uniqueness, usage limits, attribution updates, conversion idempotency, and conversion-plus-reward creation atomically. The package exports an in-memory implementation for application unit tests:
import { MemoryReferralStorage } from '@igeaman-org/referrals/testing';The memory adapter is not intended for production persistence.
Package exports
@igeaman-org/referrals— server/edge engine, URL helper, errors, and types@igeaman-org/referrals/postgres— PostgreSQL and Supabase adapter@igeaman-org/referrals/browser— browser capture and identify client@igeaman-org/referrals/testing— in-memory application-test adapter@igeaman-org/referrals/migrations/postgres.sql— packaged SQL migration
Production checklist
- Keep database credentials and hashing secrets on the server.
- Authenticate code creation, identity binding, conversions, and reward administration.
- Rate-limit public touch endpoints.
- Use stable conversion idempotency keys.
- Obtain client IPs only through correctly configured trusted proxies.
- Add application-specific fraud policies.
- Create monthly touch-table partitions before traffic reaches them.
- Monitor query latency, lock waits, connection usage, fraud rejection rate, and pending reward age.
- Test database backups and point-in-time recovery.
- Never use the in-memory adapter in production.
License
MIT
