/**
* Shared Stripe transport: authenticated form-encoded requests, bracket-style
* param serialization, cursor pagination, search pagination, money helpers,
* dry-run previews, and permission-aware errors.
*
* Auth is secret-backed (not OAuth). The default user secret is `stripeApiKey`.
* Extra Stripe accounts use `account: "work"` → secret `stripeApiKey-work`.
* Placeholders resolve on approved hosts only.
*/
export const API_BASE_URL = 'https://api.stripe.com'
export const FILES_BASE_URL = 'https://files.stripe.com'
export const API_HOST = 'api.stripe.com'
export const FILES_HOST = 'files.stripe.com'
export const DEFAULT_API_KEY_SECRET = 'stripeApiKey'
export const DEFAULT_WEBHOOK_SECRET = 'stripeWebhookSecret'
const DEFAULT_API_KEY_PLACEHOLDER = '{{secret:stripeApiKey|scope=user}}'
export const API_KEY_SETUP_URL =
'https://kody.codes/account/secrets/new?name=stripeApiKey&description=Stripe%20secret%20API%20key%20(sk_live_%20or%20rk_live_)%20for%20customers%2C%20payments%2C%20invoices%2C%20and%20subscriptions&allowedHosts=api.stripe.com,files.stripe.com&scope=user'
export const WEBHOOK_SECRET_SETUP_URL =
'https://kody.codes/account/secrets/new?name=stripeWebhookSecret&description=Stripe%20webhook%20signing%20secret%20(whsec_)%20from%20Developers%20%E2%86%92%20Webhooks&scope=user'
export const DASHBOARD_API_KEYS_URL = 'https://dashboard.stripe.com/apikeys'
export const DASHBOARD_WEBHOOKS_URL = 'https://dashboard.stripe.com/webhooks'
const SECRET_NAME_PATTERN = /^stripeApiKey(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/
export type StripeAuthOptions = {
/**
* Extra Stripe account label. `work` reads secret `stripeApiKey-work`.
* Omit (or pass `default`) for `stripeApiKey`.
*/
account?: string
/** Override the API key secret name. Must be `stripeApiKey` or `stripeApiKey-<label>`. */
secretName?: string
}
export type StripeObject = Record<string, any>
export class StripeApiError extends Error {
status: number
type: string | null
code: string | null
param: string | null
requestId: string | null
details: unknown
missingPermission: string | null
setupUrl: string
constructor(
message: string,
input: {
status: number
type?: string | null
code?: string | null
param?: string | null
requestId?: string | null
details?: unknown
missingPermission?: string | null
setupUrl?: string
},
) {
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
this.missingPermission = input.missingPermission ?? null
this.setupUrl = input.setupUrl ?? API_KEY_SETUP_URL
}
}
export function assertNever(value: never, message: string): never {
throw new Error(message + String(value))
}
export function parseAction<T extends string>(
value: unknown,
allowed: readonly T[],
fallback: T,
label: string,
): T {
const action = (value == null || value === '' ? fallback : value) as unknown
if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
return action as T
}
throw new Error(
'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
)
}
function accountLabel(value: string | undefined): string | null {
const trimmed = (value ?? '').trim()
if (!trimmed || trimmed === 'default') return null
if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
throw new Error(
'account must be a short label such as "work" or "live" (letters, numbers, _ or -).',
)
}
return trimmed
}
/** Resolve the user-scoped Stripe API key secret name for this call. */
export function resolveApiKeySecretName(input: StripeAuthOptions = {}): string {
if (input.secretName != null && String(input.secretName).trim() !== '') {
const name = String(input.secretName).trim()
if (!SECRET_NAME_PATTERN.test(name)) {
throw new Error(
'secretName must be stripeApiKey or stripeApiKey-<account>. Got ' +
name +
'. Save it at ' +
secretSetupUrl(name) +
'.',
)
}
return name
}
const account = accountLabel(input.account)
return account ? DEFAULT_API_KEY_SECRET + '-' + account : DEFAULT_API_KEY_SECRET
}
export function secretSetupUrl(secretName: string = DEFAULT_API_KEY_SECRET): string {
if (secretName === DEFAULT_API_KEY_SECRET) return API_KEY_SETUP_URL
const encoded = encodeURIComponent(secretName)
return API_KEY_SETUP_URL.replace('name=stripeApiKey', 'name=' + encoded)
}
export function webhookSecretSetupUrl(secretName: string = DEFAULT_WEBHOOK_SECRET): string {
if (secretName === DEFAULT_WEBHOOK_SECRET) return WEBHOOK_SECRET_SETUP_URL
return WEBHOOK_SECRET_SETUP_URL.replace('name=stripeWebhookSecret', 'name=' + encodeURIComponent(secretName))
}
export function resolveWebhookSecretName(input: StripeAuthOptions = {}): string {
const account = accountLabel(input.account)
return account ? DEFAULT_WEBHOOK_SECRET + '-' + account : DEFAULT_WEBHOOK_SECRET
}
function apiKeyPlaceholder(input: StripeAuthOptions = {}): string {
const name = resolveApiKeySecretName(input)
if (name === DEFAULT_API_KEY_SECRET) return DEFAULT_API_KEY_PLACEHOLDER
return '{{secret:' + name + '|scope=user}}'
}
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 = StripeAuthOptions & {
/** 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
}
function extractMissingPermission(message: string): string | null {
const having = message.match(/Having the '([^']+)' permission/i)
if (having?.[1]) return having[1]
const required = message.match(/requires the ['"]([^'"]+)['"] permission/i)
if (required?.[1]) return required[1]
const rak = message.match(/\b(rak_[a-z0-9_]+)\b/i)
return rak?.[1] ?? null
}
function permissionHint(permission: string | null, secretName: string): string {
const keyUrl = secretSetupUrl(secretName)
if (!permission) {
return (
'This Stripe key is missing a required restricted-key permission. ' +
'Create or rotate a restricted key at ' +
DASHBOARD_API_KEYS_URL +
' with the permission named in Stripe\'s error, then save it as ' +
secretName +
' at ' +
keyUrl +
'.'
)
}
return (
'Missing Stripe restricted-key permission `' +
permission +
'`. Add that permission on the key at ' +
DASHBOARD_API_KEYS_URL +
', then update secret `' +
secretName +
'` at ' +
keyUrl +
'.'
)
}
function toStripeApiError(
response: Response,
parsed: StripeObject | null,
secretName: string,
): StripeApiError {
const errorBody = (parsed?.error ?? {}) as StripeObject
const baseMessage =
typeof errorBody.message === 'string' && errorBody.message
? errorBody.message
: 'Stripe API request failed with status ' + response.status
const missingPermission = extractMissingPermission(baseMessage)
const setupUrl = secretSetupUrl(secretName)
if (response.status === 401) {
return new StripeApiError(
'Stripe rejected the API key (HTTP 401). Save a secret key (sk_...) or restricted key (rk_...) as `' +
secretName +
'`. Next step: ' +
setupUrl,
{
status: 401,
type: errorBody.type ?? 'invalid_request_error',
code: errorBody.code ?? 'invalid_api_key',
param: errorBody.param ?? null,
requestId: response.headers.get('request-id'),
details: parsed,
setupUrl,
},
)
}
if (response.status === 403) {
return new StripeApiError(baseMessage + ' ' + permissionHint(missingPermission, secretName), {
status: 403,
type: errorBody.type ?? 'invalid_request_error',
code: errorBody.code ?? 'insufficient_permissions',
param: errorBody.param ?? null,
requestId: response.headers.get('request-id'),
details: parsed,
missingPermission,
setupUrl,
})
}
return new StripeApiError(baseMessage, {
status: response.status,
type: errorBody.type ?? null,
code: errorBody.code ?? null,
param: errorBody.param ?? null,
requestId: response.headers.get('request-id'),
details: parsed,
missingPermission,
setupUrl,
})
}
/**
* 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 secretName = resolveApiKeySecretName(input)
const rawPath = input.path.startsWith('http')
? input.path
: API_BASE_URL + (input.path.startsWith('/') ? input.path : '/v1/' + input.path)
const url = new URL(rawPath)
if (url.origin !== API_BASE_URL || !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 ' + apiKeyPlaceholder(input),
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: StripeObject | null = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = { raw: text }
}
if (response.ok) return parsed
lastError = toStripeApiError(response, parsed, secretName)
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 = StripeAuthOptions & {
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: StripeObject[] = []
let startingAfter: string | undefined
let hasMore = false
do {
const page = await stripeRequest({
path,
account: options.account,
secretName: options.secretName,
query: {
...(options.query ?? {}),
...(maxItems > 0 ? { limit: Math.min(100, maxItems - items.length) } : {}),
...(startingAfter ? { starting_after: startingAfter } : {}),
},
})
const pageItems: StripeObject[] = 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 = StripeAuthOptions & {
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: StripeObject[] = []
let page: string | undefined
let hasMore = false
do {
const result = await stripeRequest({
path,
account: options.account,
secretName: options.secretName,
query: {
...(options.query ?? {}),
query: options.searchQuery,
...(maxItems > 0 ? { limit: Math.min(100, maxItems - items.length) } : {}),
...(page ? { page } : {}),
},
})
const pageItems: StripeObject[] = 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 StripeObject).id === 'string') {
return (value as StripeObject).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 dryRun: true to preview, or confirm: true to proceed.',
)
}
}
export type StripeDryRun = {
dryRun: true
action: string
method: 'POST' | 'DELETE'
path: string
body?: Record<string, unknown>
}
export type MutationGuardInput = StripeAuthOptions & {
dryRun?: boolean
confirm?: boolean
}
/**
* Preview a mutation when `dryRun: true`. Destructive helpers also require
* `confirm: true` before they contact Stripe.
*/
export function mutationPreview(
input: MutationGuardInput,
options: {
action: string
method: 'POST' | 'DELETE'
path: string
body?: Record<string, unknown>
requireConfirm?: boolean
},
): StripeDryRun | null {
if (input.dryRun === true) {
return {
dryRun: true,
action: options.action,
method: options.method,
path: options.path,
body: options.body,
}
}
if (options.requireConfirm) requireConfirm(input, options.action)
return null
}
export type StripeUploadFileInput = StripeAuthOptions & {
/** 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 secretName = resolveApiKeySecretName(input)
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(FILES_BASE_URL + '/v1/files', {
method: 'POST',
headers: { Authorization: 'Bearer ' + apiKeyPlaceholder(input) },
body: form,
})
const parsed: StripeObject = await response.json()
if (!response.ok) throw toStripeApiError(response, parsed, secretName)
return parsed
}