Enlist

SDK

The typed TypeScript SDK — a 1:1 wrapper over the REST API.

npm install @enlistdev/sdk

Constructor

import { Enlist } from '@enlistdev/sdk';

const enlist = new Enlist({
  apiKey: process.env.ENLIST_API_KEY!, // required
  baseUrl: 'https://api.enlist.dev', // override for local dev
  timeoutMs: 15_000,
  fetch: globalThis.fetch, // injectable, for tests
});

Methods

MethodReturns
waitlists.create({ name })Waitlist
waitlists.list()Page<WaitlistWithCount>
waitlists.get(id)Waitlist
waitlists.update(id, { name })Waitlist
waitlists.delete(id)void
waitlists.addSignup(id, { email, utm_source?, referrer?, referral_code?, metadata?, fields? })SignupResult
waitlists.listSignups(id, { limit?, offset? })Page<Signup>
waitlists.exportSignups(id)string (CSV)
waitlists.removeSignup(id, signupId)void
waitlists.getSignupPosition(id, { email })SignupPosition
waitlists.getStats(id, { from?, to? })WaitlistStats
waitlists.fields.list(waitlistId)Page<FieldDefinition>
waitlists.fields.create(waitlistId, { key, label, type, required?, position? })FieldDefinition
waitlists.fields.update(waitlistId, key, { label?, required?, position? })FieldDefinition
waitlists.fields.delete(waitlistId, key)void

waitlists.delete(id) takes every signup on that waitlist with it, permanently. Call waitlists.exportSignups(id) first if the addresses matter — the SDK does not do it for you.

Field names are snake_case throughout, in and out — the same names the REST API uses. There is no mapping layer, so what you read in the API reference is what you type here and what you log back.

metadata accepts Record<string, string> — max 10 keys, keys alphanumeric + underscores (max 64 chars), values max 1000 chars. It is stamped at creation and never updated on re-signup.

fields accepts Record<string, string | number | boolean> — values for any custom field definitions configured on the waitlist. A waitlist can define at most 20 of them, on every plan. Required fields produce an invalid_request error when omitted. Unknown keys are stripped silently. String values max 1000 chars. Newlines are allowed, and render as line breaks in the email body — a value interpolated into the email subject has its newlines collapsed to spaces, since a subject cannot span lines. Custom field values are available as {{key}} placeholders in the signup email template.

referral_code on the way in is someone else's, off the share link this person followed. referral_code on the way out is this person's own. An unusable code is dropped, never thrown — see Referrals.

const signup = await enlist.waitlists.addSignup(waitlistId, {
  email,
  referral_code: new URL(request.url).searchParams.get('ref') ?? undefined,
});

// Their own link, for the confirmation screen.
const shareUrl = `https://yoursite.com/?ref=${signup.referral_code}`;

Types

import type { Signup, SignupResult, WaitlistStats, FieldDefinition, Page } from '@enlistdev/sdk';

interface SignupResult {
  id: string;
  position: number;
  total: number;
  referral_code: string; // this signup's own, to share
}

interface Signup {
  id: string;
  email: string;
  position: number;
  utm_source: string | null;
  referrer: string | null;
  referral_code: string; // their own code
  referral_count: number; // how many people joined with it
  referred_by: string | null; // signup id of whoever referred them
  metadata: Record<string, string>; // {} when none was provided at signup
  fields: Record<string, string | number | boolean>; // {} when no custom fields are defined
  created_at: string;
  email_status: string | null;
  email_delivered_at: string | null;
  email_opened_at: string | null;
  email_clicked_at: string | null;
}

interface WaitlistStats {
  waitlist_id: string;
  /** All time, whatever range was asked for. */
  total: number;
  /** Signups inside the returned range — what `daily` sums to. */
  range_total: number;
  /** The range actually used; clamped forward on plans with a history cap. */
  range_from: string;
  range_to: string;
  daily: { date: string; signups: number }[];
  by_source: { source: string; signups: number }[];
  email_engagement: {
    emails_sent: number;
    /** Integers 0–100, or null before any email has been sent. */
    delivery_rate: number | null;
    open_rate: number | null;
    click_rate: number | null;
  };
  growth: {
    last_7: number;
    prev_7: number;
    /** Percent change, or null when the prior window was empty. */
    delta: number | null;
  };
  /** Top 5, descending. Empty when no referrer was captured. */
  referrer_domains: { domain: string; signups: number }[];
}

interface FieldDefinition {
  key: string;
  label: string;
  type: 'string' | 'number' | 'boolean';
  required: boolean;
  position: number;
}

Errors

import { EnlistError } from '@enlistdev/sdk';

try {
  await enlist.waitlists.addSignup(id, { email });
} catch (error) {
  if (error instanceof EnlistError && error.code === 'rate_limited') {
    await sleep((error.retryAfter ?? 60) * 1000);
  }
}

EnlistError carries status, code, and retryAfter. Network failures and timeouts surface as network_error / timeout with status 0.