1import { unwrapRedactedDeep } from "@distilled.cloud/core/protocol-rest";
2import { withRequestOptions } from "@distilled.cloud/stripe";
3import {
4 DeleteWebhookEndpoint,
5 GetWebhookEndpoints,
6 GetWebhookEndpoint,
7 CreateWebhookEndpoint,
8 UpdateWebhookEndpoint,
9 type WebhookEndpoint as StripeWebhookEndpoint,
10} from "@distilled.cloud/stripe/stripe";
11import * as Effect from "effect/Effect";
12import * as Redacted from "effect/Redacted";
13import { Unowned } from "../AdoptPolicy.ts";
14import { isResolved } from "../Diff.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import { tagRecord } from "../Tags.ts";
18import { arrayEquals } from "../Util/equal.ts";
19import {
20 alchemyMetadataKeys,
21 createInternalMetadata,
22 diffMetadata,
23 hasAlchemyMetadata,
24 stripInternalMetadata,
25 toMetadata,
26} from "./Metadata.ts";
27import type { Providers } from "./Providers.ts";
28import { isMissingStripeResource } from "./missing.ts";
29
30const LIST_PAGE_SIZE = 100;
31const LIST_MAX_PAGES = 100;
32
33export type WebhookEndpointStatus = "enabled" | "disabled";
34
35/**
36 * A Stripe event type string (`"customer.created"`) or an event class
37 * (`Stripe.CustomerCreated`) whose static `type` is used.
38 */
39export type WebhookEnabledEvent = string | { readonly type: string };
40
41const enabledEventTypes = (events: readonly WebhookEnabledEvent[]): string[] =>
42 events.map((event) => (typeof event === "string" ? event : event.type));
43
44export interface WebhookEndpointProps {
45 /**
46 * HTTPS URL Stripe POSTs event payloads to.
47 */
48 url: string;
49 /**
50 * Event types this endpoint receives. Pass Stripe event classes
51 * (`Stripe.CustomerCreated`) or type strings (`"customer.created"`).
52 * `["*"]` enables every event except those that require explicit
53 * selection.
54 */
55 enabledEvents: WebhookEnabledEvent[];
56 /**
57 * Optional description of what the webhook is used for.
58 */
59 description?: string;
60 /**
61 * Stripe API version event payloads are rendered as. Create-only —
62 * changing it replaces the endpoint. Defaults to the account version.
63 */
64 apiVersion?: string;
65 /**
66 * When true, receive events from connected accounts instead of this
67 * account. Create-only — changing it replaces the endpoint.
68 * @default false
69 */
70 connect?: boolean;
71 /**
72 * When true, the endpoint is disabled and does not receive events.
73 * @default false
74 */
75 disabled?: boolean;
76 /**
77 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
78 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
79 * not contain `:`.
80 */
81 metadata?: Record<string, string>;
82}
83
84export type WebhookEndpoint = Resource<
85 "Stripe.WebhookEndpoint",
86 WebhookEndpointProps,
87 {
88 /** Stripe webhook endpoint id (`we_…`). */
89 id: string;
90 /** HTTPS URL Stripe POSTs event payloads to. */
91 url: string;
92 /** Event types this endpoint receives. */
93 enabledEvents: string[];
94 /** Optional description of what the webhook is used for. */
95 description: string | undefined;
96 /** Stripe API version event payloads are rendered as, if set. */
97 apiVersion: string | undefined;
98 /** Associated Connect application id, if this is a Connect endpoint. */
99 application: string | undefined;
100 /** Whether this endpoint receives events from connected accounts. */
101 connect: boolean;
102 /** Whether the endpoint is currently receiving events. */
103 status: WebhookEndpointStatus;
104 /**
105 * Signing secret, returned only at creation and preserved in state.
106 * Never log this value.
107 */
108 secret: Redacted.Redacted<string> | undefined;
109 /** User-defined metadata (Alchemy ownership keys stripped). */
110 metadata: Record<string, string>;
111 /** Unix timestamp when the endpoint was created. */
112 created: number;
113 /** Whether the endpoint exists in live mode. */
114 livemode: boolean;
115 },
116 never,
117 Providers
118>;
119
120/**
121 * A Stripe webhook endpoint — the HTTPS destination Stripe POSTs
122 * event payloads to. URL, enabled events, description, disabled, and
123 * metadata update in place. The signing `secret` is returned only on
124 * create and is stored as `Redacted`. Deleting the resource deletes
125 * the endpoint.
126 *
127 * @see https://docs.stripe.com/api/webhook_endpoints
128 *
129 * ### Creating an endpoint
130 * **Example:** Charge events
131 * ```typescript
132 * const webhook = yield* Stripe.WebhookEndpoint("Charges", {
133 * url: "https://example.com/alchemy-stripe-webhook",
134 * enabledEvents: ["charge.succeeded", "charge.failed"],
135 * });
136 * ```
137 *
138 * **Example:** Event classes, for a Worker URL you already have
139 * ```typescript
140 * const webhook = yield* Stripe.WebhookEndpoint("Events", {
141 * url: Output.interpolate`${api.url}/webhooks/stripe`,
142 * enabledEvents: [Stripe.CustomerCreated, Stripe.InvoicePaid],
143 * });
144 * yield* Stripe.bindWebhookSecret(api, webhook.secret);
145 * ```
146 *
147 * On a Worker, prefer `consumeEvents` — it provisions this endpoint and
148 * binds the secret for you.
149 *
150 * **Example:** Description, metadata, and all events
151 * ```typescript
152 * const webhook = yield* Stripe.WebhookEndpoint("AllEvents", {
153 * url: "https://example.com/alchemy-stripe-webhook",
154 * enabledEvents: ["*"],
155 * description: "Alchemy test webhook",
156 * metadata: { purpose: "billing" },
157 * });
158 * ```
159 *
160 * ### Disabling an endpoint
161 * **Example:** Disable without deleting
162 * ```typescript
163 * const webhook = yield* Stripe.WebhookEndpoint("Charges", {
164 * url: "https://example.com/alchemy-stripe-webhook",
165 * enabledEvents: ["charge.succeeded"],
166 * disabled: true,
167 * });
168 * ```
169 *
170 * @resource
171 * @product Webhook
172 */
173export const WebhookEndpoint = Resource<WebhookEndpoint>(
174 "Stripe.WebhookEndpoint",
175);
176
177const userMetadata = (
178 metadata: Record<string, string | undefined> | null | undefined,
179): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
180
181const redactSecret = (
182 secret: string | Redacted.Redacted<string> | undefined,
183): Redacted.Redacted<string> | undefined => {
184 if (secret === undefined) return undefined;
185 if (typeof secret === "string") return Redacted.make(secret);
186 // Distilled and alchemy can resolve different `effect` copies, so a
187 // SDK-wrapped Redacted is not readable via alchemy's WeakMap. Unwrap
188 // with distilled first, then wrap with ours so state encoding works.
189 try {
190 return Redacted.make(Redacted.value(secret));
191 } catch {
192 const unwrapped = unwrapRedactedDeep(secret);
193 return typeof unwrapped === "string" ? Redacted.make(unwrapped) : undefined;
194 }
195};
196
197const toAttrs = (
198 endpoint: StripeWebhookEndpoint,
199 previousSecret?: Redacted.Redacted<string>,
200) => ({
201 id: endpoint.id,
202 url: endpoint.url,
203 enabledEvents: endpoint.enabled_events,
204 description: endpoint.description ?? undefined,
205 apiVersion: endpoint.api_version ?? undefined,
206 application: endpoint.application ?? undefined,
207 connect: endpoint.application != null,
208 status: (endpoint.status === "disabled"
209 ? "disabled"
210 : "enabled") as WebhookEndpointStatus,
211 secret: redactSecret(endpoint.secret) ?? previousSecret,
212 metadata: userMetadata(endpoint.metadata),
213 created: endpoint.created,
214 livemode: endpoint.livemode,
215});
216
217const isMissingWebhookEndpoint = isMissingStripeResource;
218
219const getById = (webhookEndpoint: string) =>
220 GetWebhookEndpoint({
221 webhook_endpoint: webhookEndpoint,
222 }).pipe(
223 Effect.catchIf(isMissingWebhookEndpoint, () => Effect.succeed(undefined)),
224 );
225
226const listAllWebhookEndpoints = Effect.fn(function* () {
227 const endpoints: StripeWebhookEndpoint[] = [];
228 let startingAfter: string | undefined;
229 for (let page = 0; page < LIST_MAX_PAGES; page++) {
230 const response = yield* GetWebhookEndpoints({
231 limit: LIST_PAGE_SIZE,
232 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
233 });
234 endpoints.push(...response.data);
235 if (!response.has_more || response.data.length === 0) {
236 break;
237 }
238 startingAfter = response.data[response.data.length - 1]?.id;
239 if (startingAfter === undefined) {
240 break;
241 }
242 }
243 return endpoints;
244});
245
246const findByAlchemyId = Effect.fn(function* (id: string) {
247 const endpoints = yield* listAllWebhookEndpoints();
248 const matches: StripeWebhookEndpoint[] = [];
249 for (const endpoint of endpoints) {
250 if (yield* hasAlchemyMetadata(id, tagRecord(endpoint.metadata))) {
251 matches.push(endpoint);
252 }
253 }
254 matches.sort((a, b) => b.created - a.created);
255 return matches[0];
256});
257
258const observe = Effect.fn(function* (input: {
259 id?: string;
260 logicalId: string;
261}) {
262 if (input.id !== undefined) {
263 const byId = yield* getById(input.id);
264 if (byId !== undefined) return byId;
265 }
266 return yield* findByAlchemyId(input.logicalId);
267});
268
269const desiredMetadata = Effect.fn(function* (
270 id: string,
271 metadata: Record<string, string> | undefined,
272) {
273 return {
274 ...toMetadata(metadata),
275 ...(yield* createInternalMetadata(id)),
276 };
277});
278
279const shouldReplace = (
280 news: WebhookEndpointProps,
281 output: WebhookEndpoint["Attributes"] | undefined,
282) => {
283 if (output === undefined) return false;
284 if (news.apiVersion !== undefined && news.apiVersion !== output.apiVersion) {
285 return true;
286 }
287 if (news.connect !== undefined && news.connect !== output.connect) {
288 return true;
289 }
290 return false;
291};
292
293export const WebhookEndpointProvider = () =>
294 Provider.succeed(WebhookEndpoint, {
295 stables: [
296 "id",
297 "secret",
298 "apiVersion",
299 "connect",
300 "application",
301 "created",
302 "livemode",
303 ],
304
305 diff: Effect.fn(function* ({ news, output }) {
306 if (!isResolved(news)) return undefined;
307 if (shouldReplace(news, output)) {
308 return { action: "replace" } as const;
309 }
310 return undefined;
311 }),
312
313 read: Effect.fn(function* ({ id, output }) {
314 const existing = yield* observe({
315 id: output?.id,
316 logicalId: id,
317 });
318 if (existing === undefined) return undefined;
319 const attrs = toAttrs(existing, output?.secret);
320 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
321 ? attrs
322 : Unowned(attrs);
323 }),
324
325 list: Effect.fn(function* () {
326 const endpoints = yield* listAllWebhookEndpoints();
327 return endpoints
328 .filter((endpoint) => {
329 const metadata = tagRecord(endpoint.metadata);
330 return metadata[alchemyMetadataKeys.stack] !== undefined;
331 })
332 .map((endpoint) => toAttrs(endpoint));
333 }),
334
335 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
336 const metadata = yield* desiredMetadata(id, news.metadata);
337 const desiredDescription = news.description ?? "";
338 const desiredDisabled = news.disabled ?? false;
339 const desiredUrl = news.url;
340 const desiredEvents = enabledEventTypes(news.enabledEvents);
341 const previousSecret = output?.secret;
342
343 let current = yield* observe({
344 id: output?.id,
345 logicalId: id,
346 });
347
348 if (current === undefined) {
349 current = yield* CreateWebhookEndpoint({
350 url: desiredUrl,
351 enabled_events: desiredEvents,
352 ...(desiredDescription.length > 0
353 ? { description: desiredDescription }
354 : {}),
355 ...(news.apiVersion !== undefined
356 ? { api_version: news.apiVersion }
357 : {}),
358 ...(news.connect !== undefined ? { connect: news.connect } : {}),
359 metadata,
360 }).pipe(
361 withRequestOptions({
362 idempotencyKey: `alchemy-webhook-endpoint-${instanceId}`,
363 }),
364 );
365 }
366
367 const observedMetadata = tagRecord(current.metadata);
368 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
369 const metadataChanged = upsert.length > 0 || removed.length > 0;
370 const urlChanged = current.url !== desiredUrl;
371 const eventsChanged = !arrayEquals(current.enabled_events, desiredEvents);
372 const descriptionChanged =
373 (current.description ?? "") !== desiredDescription;
374 const observedDisabled = current.status === "disabled";
375 const disabledChanged = observedDisabled !== desiredDisabled;
376
377 if (
378 !urlChanged &&
379 !eventsChanged &&
380 !descriptionChanged &&
381 !disabledChanged &&
382 !metadataChanged
383 ) {
384 return toAttrs(current, previousSecret);
385 }
386
387 const updated = yield* UpdateWebhookEndpoint({
388 webhook_endpoint: current.id,
389 ...(urlChanged ? { url: desiredUrl } : {}),
390 ...(eventsChanged ? { enabled_events: desiredEvents } : {}),
391 ...(descriptionChanged ? { description: desiredDescription } : {}),
392 ...(disabledChanged ? { disabled: desiredDisabled } : {}),
393 ...(metadataChanged
394 ? {
395 metadata: {
396 ...Object.fromEntries(
397 upsert.map((tag) => [tag.Key, tag.Value]),
398 ),
399 ...Object.fromEntries(removed.map((key) => [key, ""])),
400 },
401 }
402 : {}),
403 });
404 return toAttrs(updated, previousSecret);
405 }),
406
407 delete: Effect.fn(function* ({ output }) {
408 yield* DeleteWebhookEndpoint({
409 webhook_endpoint: output.id,
410 }).pipe(Effect.catchIf(isMissingWebhookEndpoint, () => Effect.void));
411 }),
412 });