← Public packages
@kentcdodds/kit
Kit.com helpers for subscribers, tags, forms, sequences, and broadcasts.
src/kit-client.js
321 lines · 8.9 KB · JavaScript/**
* Domain helpers over the OpenAPI-scaffolded Kit client.
* Transport: ./openapi-client.js (headerSecret kitApiKey → X-Kit-Api-Key).
*/
import {
deleteV4BroadcastsId,
deleteV4BulkTags,
deleteV4TagsTagIdSubscribersId,
getV4Account,
getV4AccountEmailStats,
getV4AccountGrowthStats,
getV4Broadcasts,
getV4BroadcastsBroadcastIdStats,
getV4BroadcastsId,
getV4BroadcastsStats,
getV4Forms,
getV4Segments,
getV4Sequences,
getV4SequencesId,
getV4SequencesSequenceIdEmails,
getV4SequencesSequenceIdEmailsId,
getV4Subscribers,
getV4SubscribersId,
getV4SubscribersSubscriberIdTags,
getV4Tags,
getV4TagsTagIdSubscribers,
postV4Broadcasts,
postV4FormsFormIdSubscribers,
postV4Sequences,
postV4SequencesSequenceIdEmails,
postV4SequencesSequenceIdSubscribers,
postV4Subscribers,
postV4SubscribersIdUnsubscribe,
postV4Tags,
postV4TagsTagIdSubscribers,
putV4BroadcastsId,
putV4SequencesSequenceIdEmailsId,
putV4SubscribersId,
} from './openapi-client.js'
/** @typedef {Record<string, string | number | boolean | null | undefined>} KitQuery */
/** @typedef {{ params?: Record<string, unknown>, query?: KitQuery, headers?: Record<string, string>, body?: unknown }} ScaffoldInput */
/** @typedef {{ fetchImpl?: typeof fetch }} ScaffoldOptions */
export const KIT_API_BASE_URL = 'https://api.kit.com/v4'
export const KIT_SECRET_NAME = 'kitApiKey'
/** Known email template ids on Kent's Kit account. Verify in Kit before relying on new ids. */
export const KIT_TEMPLATES = {
textOnly: 684651,
epePodcast: 5215129,
/** "Better with Kent (opt-out link)" — required for BWK YouTube episode emails. */
betterWithKent: 5341436,
}
/**
* Known mute tags used in broadcast `subscriber_filter` `none` clauses.
* Always exclude the matching mute tag for series emails so opted-out people
* are not re-mailed.
*/
export const KIT_MUTE_TAGS = {
/** `mute: Better with Kent YouTube emails` */
betterWithKentYoutube: 20860981,
}
/**
* Required Kit fields for Better with Kent YouTube early-access episode emails.
* Match prior completed BWK broadcasts (08–10+). Prefer
* `createBwkYoutubeBroadcastDraft` so these cannot be omitted by accident.
*/
export const BWK_YOUTUBE_BROADCAST_DEFAULTS = {
email_address: 'hello@kentcdodds.com',
email_template_id: KIT_TEMPLATES.betterWithKent,
public: false,
subscriber_filter: [
{
none: [
{
type: 'tag',
ids: [KIT_MUTE_TAGS.betterWithKentYoutube],
},
],
},
],
}
/** EPE podcast sequence — packages/bepe/kit-sequence/sequence.json */
export const EPE_SEQUENCE_ID = 2757781
export class KitApiError extends Error {
/**
* @param {string} message
* @param {{ status?: number, body?: unknown, method?: string, path?: string }} [meta]
*/
constructor(message, meta = {}) {
super(message)
this.name = 'KitApiError'
this.status = meta.status
this.body = meta.body
this.method = meta.method
this.path = meta.path
}
}
/**
* @param {Response} response
* @param {{ method?: string, path?: string }} [meta]
*/
export async function parseOkJson(response, meta = {}) {
const text = await response.text()
let parsed
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = text
}
if (!response.ok) {
const message =
typeof parsed === 'object' &&
parsed &&
Array.isArray(/** @type {{ errors?: string[] }} */ (parsed).errors)
? /** @type {{ errors: string[] }} */ (parsed).errors.join('; ')
: `Kit API ${response.status} ${meta.method ?? response.statusText} ${meta.path ?? ''}`
throw new KitApiError(message, {
status: response.status,
body: parsed,
method: meta.method,
path: meta.path,
})
}
return parsed
}
/**
* @param {(input?: ScaffoldInput, options?: ScaffoldOptions) => Promise<Response>} fn
* @param {ScaffoldInput} [input]
* @param {{ method?: string, path?: string }} [meta]
*/
export async function callParsed(fn, input = {}, meta = {}) {
return parseOkJson(await fn(input), meta)
}
/**
* Cursor-paginate a list endpoint. Without `maxItems` this walks every page,
* which on large accounts can exhaust the execute timeout — prefer passing
* `maxItems` for exploratory reads.
* @param {(input?: ScaffoldInput) => Promise<Response>} fn
* @param {string} itemKey
* @param {KitQuery} [query]
* @param {{ maxItems?: number }} [options]
*/
export async function listAll(fn, itemKey, query = {}, options = {}) {
const maxItems = options.maxItems
/** @type {unknown[]} */
const items = []
let after
for (;;) {
const perPage = maxItems ? Math.min(100, maxItems - items.length) : 100
const page = await callParsed(
fn,
{ query: { per_page: perPage, ...query, ...(after ? { after } : {}) } },
{ method: 'GET', path: itemKey },
)
const record = /** @type {Record<string, unknown>} */ (page)
const chunk = record[itemKey]
if (Array.isArray(chunk)) items.push(...chunk)
if (maxItems && items.length >= maxItems) return items.slice(0, maxItems)
const pagination = /** @type {{ has_next_page?: boolean, end_cursor?: string } | undefined} */ (
record.pagination
)
if (!pagination?.has_next_page || !pagination.end_cursor) break
after = pagination.end_cursor
}
return items
}
/** @param {{ id?: number, subject?: string, status?: string }} broadcast */
export function broadcastSummary(broadcast) {
if (!broadcast?.id) return null
return {
id: broadcast.id,
subject: broadcast.subject,
status: broadcast.status,
editUrl: `https://app.kit.com/campaigns/${broadcast.id}/draft`,
}
}
/** @param {{ id?: number, email_address?: string, first_name?: string, state?: string, created_at?: string }} subscriber */
export function subscriberSummary(subscriber) {
if (!subscriber?.id) return null
return {
id: subscriber.id,
email_address: subscriber.email_address,
first_name: subscriber.first_name,
state: subscriber.state,
created_at: subscriber.created_at,
}
}
/** @param {{ id?: number, sequence_id?: number, subject?: string, published?: boolean }} email */
export function sequenceEmailSummary(email) {
if (!email?.id) return null
return {
id: email.id,
sequenceId: email.sequence_id,
subject: email.subject,
published: email.published,
}
}
/** GET /v4/account */
export async function getCurrentAccount(query = {}) {
return callParsed(getV4Account, { query }, { method: 'GET', path: '/v4/account' })
}
/** GET /v4/account/email_stats */
export async function getAccountEmailStats(query = {}) {
return callParsed(
getV4AccountEmailStats,
{ query },
{ method: 'GET', path: '/v4/account/email_stats' },
)
}
/** GET /v4/account/growth_stats */
export async function getAccountGrowthStats(query = {}) {
return callParsed(
getV4AccountGrowthStats,
{ query },
{ method: 'GET', path: '/v4/account/growth_stats' },
)
}
/** GET /v4/broadcasts/{broadcast_id}/stats */
export async function getBroadcastStats(broadcastId, query = {}) {
if (!broadcastId) throw new Error('getBroadcastStats: broadcastId is required')
return callParsed(
getV4BroadcastsBroadcastIdStats,
{ params: { broadcast_id: broadcastId }, query },
{ method: 'GET', path: `/v4/broadcasts/${broadcastId}/stats` },
)
}
/** GET /v4/broadcasts/stats */
export async function getBroadcastsStats(query = {}) {
return callParsed(
getV4BroadcastsStats,
{ query },
{ method: 'GET', path: '/v4/broadcasts/stats' },
)
}
/**
* GET /v4/sequences
* @param {KitQuery & { maxItems?: number }} [query] — `maxItems` caps pagination.
*/
export async function listSequences(query = {}) {
const { maxItems, ...rest } = query
return listAll(getV4Sequences, 'sequences', rest, { maxItems })
}
/** GET /v4/sequences/{id} */
export async function getSequence(id, query = {}) {
if (!id) throw new Error('getSequence: id is required')
return callParsed(
getV4SequencesId,
{ params: { id }, query },
{ method: 'GET', path: `/v4/sequences/${id}` },
)
}
/**
* GET /v4/sequences/{sequence_id}/emails
* @param {number | string} sequenceId
* @param {KitQuery & { maxItems?: number }} [query] — `maxItems` caps pagination.
*/
export async function listSequenceEmails(sequenceId, query = {}) {
if (!sequenceId) throw new Error('listSequenceEmails: sequenceId is required')
const { maxItems, ...rest } = query
return listAll(
(input) =>
getV4SequencesSequenceIdEmails({
...input,
params: { sequence_id: sequenceId, ...(input?.params ?? {}) },
}),
'emails',
rest,
{ maxItems },
)
}
// Re-export scaffold ops used by domain modules
export {
deleteV4BroadcastsId,
deleteV4BulkTags,
deleteV4TagsTagIdSubscribersId,
getV4Broadcasts,
getV4BroadcastsId,
getV4Forms,
getV4Segments,
getV4SequencesSequenceIdEmailsId,
getV4Subscribers,
getV4SubscribersId,
getV4SubscribersSubscriberIdTags,
getV4Tags,
getV4TagsTagIdSubscribers,
postV4Broadcasts,
postV4FormsFormIdSubscribers,
postV4Sequences,
postV4SequencesSequenceIdEmails,
postV4SequencesSequenceIdSubscribers,
postV4Subscribers,
postV4SubscribersIdUnsubscribe,
postV4Tags,
postV4TagsTagIdSubscribers,
putV4BroadcastsId,
putV4SequencesSequenceIdEmailsId,
putV4SubscribersId,
}