← Public packages
@kentcdodds/stripe
Stripe helpers for customers, payments, invoices, subscriptions, products, payment links, refunds, and balance.
src/stripe-core.ts
283 lines · 9.6 KB · TypeScript/**
* Shared Stripe transport: authenticated form-encoded requests, bracket-style
* param serialization, cursor pagination, search pagination, and money helpers.
* Auth uses the user-scoped `stripeSecretKey` secret placeholder resolved by
* the Kody gateway on approved hosts only.
*/
const apiBaseUrl = 'https://api.stripe.com'
const filesBaseUrl = 'https://files.stripe.com'
const secretKey = '{{secret:stripeSecretKey|scope=user}}'
export class StripeApiError extends Error {
status: number
type: string | null
code: string | null
param: string | null
requestId: string | null
details: unknown
constructor(
message: string,
input: {
status: number
type?: string | null
code?: string | null
param?: string | null
requestId?: string | null
details?: unknown
},
) {
super(message)
this.name = 'StripeApiError'
this.status = input.status
this.type = input.type ?? null
this.code = input.code ?? null
this.param = input.param ?? null
this.requestId = input.requestId ?? null
this.details = input.details ?? null
}
}
function appendParam(params: URLSearchParams, key: string, value: unknown) {
if (value === undefined || value === null || value === '') return
if (value instanceof Date) {
params.append(key, String(Math.floor(value.getTime() / 1000)))
} else if (Array.isArray(value)) {
for (const item of value) appendParam(params, key + '[]', item)
} else if (typeof value === 'object') {
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
appendParam(params, key + '[' + childKey + ']', childValue)
}
} else {
params.append(key, String(value))
}
}
/** Serialize nested params into Stripe's bracket/array form encoding. */
export function stripeParams(input: Record<string, unknown> = {}) {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(input)) appendParam(params, key, value)
return params
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export type StripeRequestInput = {
/** Path under /v1, e.g. 'customers' or '/v1/customers/cus_123'. */
path: string
method?: 'GET' | 'POST' | 'DELETE'
/** Query params (GET) serialized with Stripe bracket encoding. Supports `expand` arrays. */
query?: Record<string, unknown>
/** Body params (POST) serialized as application/x-www-form-urlencoded. */
body?: Record<string, unknown>
headers?: Record<string, string>
/** Sent as Idempotency-Key. Required to retry POSTs safely. */
idempotencyKey?: string
maxAttempts?: number
}
/**
* Authenticated Stripe request with parse + typed error + retry on 429/5xx.
* POST requests are retried only when an idempotencyKey is provided.
*/
export async function stripeRequest(input: StripeRequestInput): Promise<any> {
const method = input.method ?? 'GET'
const rawPath = input.path.startsWith('http')
? input.path
: apiBaseUrl + (input.path.startsWith('/') ? input.path : '/v1/' + input.path)
const url = new URL(rawPath)
if (url.origin !== apiBaseUrl || !url.pathname.startsWith('/v1/')) {
throw new Error('Stripe requests must stay on https://api.stripe.com/v1.')
}
const query = stripeParams(input.query)
for (const [key, value] of query.entries()) url.searchParams.append(key, value)
const body = method === 'GET' ? undefined : stripeParams(input.body)
const maxAttempts = Math.max(1, input.maxAttempts ?? 3)
const canRetry = method !== 'POST' || Boolean(input.idempotencyKey)
let lastError: Error | null = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const response = await fetch(url, {
method,
headers: {
Authorization: 'Bearer ' + secretKey,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
...(input.idempotencyKey ? { 'Idempotency-Key': input.idempotencyKey } : {}),
...(input.headers ?? {}),
},
body,
})
const text = await response.text()
let parsed: any = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = { raw: text }
}
if (response.ok) return parsed
const errorBody = parsed?.error ?? {}
lastError = new StripeApiError(
errorBody.message ?? 'Stripe API request failed with status ' + response.status,
{
status: response.status,
type: errorBody.type ?? null,
code: errorBody.code ?? null,
param: errorBody.param ?? null,
requestId: response.headers.get('request-id'),
details: parsed,
},
)
const retryable = response.status === 429 || response.status >= 500
if (!retryable || !canRetry || attempt >= maxAttempts) throw lastError
const retryAfterHeader = response.headers.get('retry-after')
const retryMs = retryAfterHeader
? Number(retryAfterHeader) * 1000
: Math.min(5000, 500 * 2 ** (attempt - 1))
await sleep(Number.isFinite(retryMs) ? retryMs : 1000)
}
throw lastError ?? new Error('Stripe request failed.')
}
export type StripeListOptions = {
query?: Record<string, unknown>
/** Follow has_more cursors until this many items are collected (default: one page). */
maxItems?: number
}
/** List a Stripe collection endpoint, following starting_after cursors up to maxItems. */
export async function stripeList(path: string, options: StripeListOptions = {}) {
const maxItems = options.maxItems ?? 0
const items: any[] = []
let startingAfter: string | undefined
let hasMore = false
do {
const page = await stripeRequest({
path,
query: {
...(options.query ?? {}),
...(maxItems > 0 ? { limit: Math.min(100, maxItems - items.length) } : {}),
...(startingAfter ? { starting_after: startingAfter } : {}),
},
})
const pageItems: any[] = Array.isArray(page?.data) ? page.data : []
items.push(...pageItems)
hasMore = Boolean(page?.has_more) && pageItems.length > 0
startingAfter = pageItems.at(-1)?.id
} while (hasMore && maxItems > 0 && items.length < maxItems)
return { items, hasMore }
}
export type StripeSearchOptions = {
searchQuery: string
query?: Record<string, unknown>
maxItems?: number
}
/** Search a Stripe search endpoint, following next_page tokens up to maxItems. */
export async function stripeSearch(path: string, options: StripeSearchOptions) {
const maxItems = options.maxItems ?? 0
const items: any[] = []
let page: string | undefined
let hasMore = false
do {
const result = await stripeRequest({
path,
query: {
...(options.query ?? {}),
query: options.searchQuery,
...(maxItems > 0 ? { limit: Math.min(100, maxItems - items.length) } : {}),
...(page ? { page } : {}),
},
})
const pageItems: any[] = Array.isArray(result?.data) ? result.data : []
items.push(...pageItems)
hasMore = Boolean(result?.has_more) && typeof result?.next_page === 'string'
page = result?.next_page ?? undefined
} while (hasMore && maxItems > 0 && items.length < maxItems)
return { items, hasMore }
}
const zeroDecimalCurrencies = new Set([
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
])
/** Format a Stripe integer amount (smallest currency unit) as a display string. */
export function formatStripeAmount(amount: unknown, currency: unknown) {
if (typeof amount !== 'number' || !Number.isFinite(amount)) return null
const code = typeof currency === 'string' ? currency.toLowerCase() : 'usd'
const value = zeroDecimalCurrencies.has(code) ? amount : amount / 100
try {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: code.toUpperCase() }).format(value)
} catch {
return value.toFixed(2) + ' ' + code.toUpperCase()
}
}
/** Convert a Stripe unix timestamp (seconds) to an ISO string, or null. */
export function stripeDate(seconds: unknown) {
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return null
return new Date(seconds * 1000).toISOString()
}
/** Extract the id when Stripe returns either an id string or an expanded object. */
export function idOf(value: unknown): string | null {
if (typeof value === 'string') return value
if (value && typeof value === 'object' && typeof (value as any).id === 'string') {
return (value as any).id
}
return null
}
/** Throw unless the caller passed confirm: true for a money-moving/destructive action. */
export function requireConfirm(input: { confirm?: boolean }, action: string) {
if (input.confirm !== true) {
throw new Error(
'Refusing to ' + action + ' without confirm: true. ' +
'This action changes live Stripe state; pass confirm: true to proceed.',
)
}
}
export type StripeUploadFileInput = {
/** Stripe file purpose, e.g. 'dispute_evidence' or 'invoice_statement_descriptor'. */
purpose: string
fileName: string
/** File content as UTF-8 text, or base64 when encoding is 'base64'. */
content: string
encoding?: 'utf-8' | 'base64'
contentType?: string
}
/** Upload a file to files.stripe.com (multipart). */
export async function stripeUploadFile(input: StripeUploadFileInput): Promise<any> {
const bytes =
input.encoding === 'base64'
? Uint8Array.from(atob(input.content), (char) => char.charCodeAt(0))
: new TextEncoder().encode(input.content)
const form = new FormData()
form.set('purpose', input.purpose)
form.set(
'file',
new Blob([bytes], { type: input.contentType ?? 'application/octet-stream' }),
input.fileName,
)
const response = await fetch(filesBaseUrl + '/v1/files', {
method: 'POST',
headers: { Authorization: 'Bearer ' + secretKey },
body: form,
})
const parsed: any = await response.json()
if (!response.ok) {
throw new StripeApiError(parsed?.error?.message ?? 'Stripe file upload failed', {
status: response.status,
type: parsed?.error?.type ?? null,
requestId: response.headers.get('request-id'),
details: parsed,
})
}
return parsed
}