SDK
The typed TypeScript SDK — a 1:1 wrapper over the REST API.
npm install @enlistdev/sdkConstructor
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
| Method | Returns |
|---|---|
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, reversiblyput 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.