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?, referral_bonus? })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
integrations.list()Integration[]
integrations.put(type, { config })IntegrationCreated
integrations.setEnabled(type, enabled)Integration
integrations.delete(type)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}`;

referral_bonus on waitlists.update sets how many positions a signup moves up per successful referral. It only affects display_position; the real position never moves. Pass null to turn it off.

Integrations

Everything on /v1/integrations, typed. Six adapters: webhook, slack, discord, loops, posthog, kit.

import type { SlackIntegrationConfig } from '@enlistdev/sdk';

await enlist.integrations.put('slack', {
  config: {
    webhook_url: process.env.SLACK_WEBHOOK_URL!,
    events: ['signup.created', 'signup.removed'],
  },
});

await enlist.integrations.setEnabled('slack', false); // pause, reversibly

put is keyed on its first argument, so a Slack config handed to put('discord', …) is a compile error rather than a 400. Config shapes are one per type and documented in the API reference — the SDK's interfaces are the same keys, snake_case, so the two read alike.

Config is write-only. Nothing on this resource ever returns it, secret fields included, which is also why omitting a secret from put keeps the stored one rather than clearing it. Creating a webhook without a signing_secret generates one and returns it in signing_secret on that one response; every later call returns null.

setEnabled(type, false) pauses delivery and keeps the config, the signing secret and the delivery history. delete(type) destroys all three, and adding the integration back issues a new signing secret every receiver has to be updated with.

Integrations are a Launch feature. put and setEnabled(type, true) throw quota_exceeded on Free; list, setEnabled(type, false) and delete work on every plan, so an account that downgrades can still see what it configured and switch it off. Delivery stops at the downgrade — a stored config does not keep firing on a plan that no longer includes it.

There is no listDeliveries, though GET /v1/integrations/:type/deliveries exists and is documented. The delivery log is a debugging read for a human looking at a dashboard, and every method here is surface that has to be carried forever. Reach it with enlist.request if you need it.

Types

import type {
  Signup,
  SignupResult,
  WaitlistStats,
  FieldDefinition,
  Page,
  Integration,
  IntegrationCreated,
  IntegrationType,
  IntegrationEventType,
} 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;
  /** `max(1, position - referral_count * referral_bonus)`. Null when the waitlist has no referral_bonus set. */
  display_position: number | null;
  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 }[];
  /** All-time signups that arrived via a referral link. */
  referred_count: number;
}

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

type IntegrationType = 'webhook' | 'slack' | 'discord' | 'loops' | 'posthog' | 'kit';

type IntegrationEventType =
  | 'signup.created'
  | 'signup.removed'
  | 'signup.email_delivered'
  | 'signup.email_opened'
  | 'signup.email_clicked';

interface Integration {
  type: IntegrationType;
  enabled: boolean;
  created_at: string;
}

interface IntegrationCreated {
  type: IntegrationType;
  enabled: boolean;
  /** Only on the response that first creates a webhook without one. Null otherwise, and never retrievable again. */
  signing_secret: string | null;
}

The per-type config interfaces are exported too — WebhookIntegrationConfig, SlackIntegrationConfig, DiscordIntegrationConfig, LoopsIntegrationConfig, PosthogIntegrationConfig, KitIntegrationConfig — though put infers the right one from its first argument, so you rarely name them.

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.