import type { Webflow } from "./util/types"; import { type DeepPartial, type PagingOptions } from "./util/misc"; import crypto from "node:crypto"; const parsePayload = (res: Response): Promise => res.json().catch(() => res.text().catch(() => undefined)); type ClientOptions = { siteId: string; collectionsId: string; token: string; webhookSecret: string; }; type Credentials = { apiBaseUrl: string; apiSitesUrl: string; apiCollectionsUrl: string; authHeader: Record; }; export class WebflowError extends Error { constructor( message: string, public upstreamStatus: number, public payload: unknown, public status: number = 502 ) { super(message); this.name = "WebflowError"; } } class SkusClient { constructor(private creds: Credentials) { } async create( wProductId: string, skus: DeepPartial[], ): Promise { const res = await fetch( `${this.creds.apiSitesUrl}/products/${wProductId}/skus`, { method: "POST", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify({ skus }), }, ); if (!res.ok) throw new WebflowError("Failed to create Webflow product SKU", res.status, await parsePayload(res)); const payload = (await res.json()) as { skus: { id: string }[] }; return payload.skus.map((sku) => sku.id); } async update( wProductId: string, wSkuId: string, wSku: DeepPartial, ): Promise { const res = await fetch( `${this.creds.apiSitesUrl}/products/${wProductId}/skus/${wSkuId}`, { method: "PATCH", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify({ sku: wSku }), }, ); if (!res.ok) throw new WebflowError("Failed to update Webflow product SKU", res.status, await parsePayload(res)); } } class ProductsClient { readonly Skus: SkusClient; constructor(private creds: Credentials) { this.Skus = new SkusClient(creds); } async create( wProductAndSku: DeepPartial, ): Promise { const res = await fetch(`${this.creds.apiSitesUrl}/products`, { method: "POST", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify(wProductAndSku), }); if (!res.ok) throw new WebflowError("Failed to create Webflow product", res.status, await parsePayload(res)); const payload = (await res.json()) as { product: { id: string } }; return payload.product.id; } async list(opt: PagingOptions = {}): Promise { const allWProducts: Webflow.Products.ProductAndSkus[] = []; let offset = opt.offset ?? 0; do { const res = await fetch(`${this.creds.apiSitesUrl}/products?offset=${offset}&limit=${opt.limit ?? 100}`, { method: "GET", headers: this.creds.authHeader, }); if (!res.ok) throw new WebflowError("Failed to list Webflow products", res.status, await parsePayload(res)); const payload = (await res.json()) as Webflow.MetaDataMulti<"items", Webflow.Products.ProductAndSkus>; allWProducts.push(...payload.items); offset = allWProducts.length; if (allWProducts.length >= (payload?.pagination?.total ?? 0)) break; } while (opt.forceAll); return allWProducts; } async get(wProductId: string): Promise { const res = await fetch(`${this.creds.apiSitesUrl}/products/${wProductId}`, { method: "GET", headers: this.creds.authHeader, }); if (res.status === 404 || res.status === 400) return; if (!res.ok) throw new WebflowError("Failed to get Webflow product", res.status, await parsePayload(res)); return (await res.json()) as Webflow.Products.ProductAndSkus; } async update( wProductId: string, wProduct: DeepPartial, ): Promise { const res = await fetch(`${this.creds.apiSitesUrl}/products/${wProductId}`, { method: "PATCH", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify(wProduct), }); if (!res.ok) throw new WebflowError("Failed to update Webflow product", res.status, await parsePayload(res)); } async remove(wProductId: string): Promise { const res = await fetch( `${this.creds.apiCollectionsUrl}/items/${wProductId}`, { method: "DELETE", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify({}), }, ); if (!res.ok) throw new WebflowError("Failed to remove Webflow product", res.status, await parsePayload(res)); } } class OrdersClient { constructor(private creds: Credentials) { } async list(opt: PagingOptions & { status?: Webflow.Orders.Order["status"] } = {}): Promise { const allOrders: Webflow.Orders.Order[] = []; let offset = opt.offset ?? 0; do { const params = new URLSearchParams(); if (opt.status !== undefined) params.set("status", opt.status); params.set("offset", String(offset)); params.set("limit", String(opt.limit ?? 100)); const res = await fetch(`${this.creds.apiSitesUrl}/orders?${params.toString()}`, { method: "GET", headers: this.creds.authHeader, }); if (!res.ok) throw new WebflowError("Failed to list Webflow orders", res.status, await parsePayload(res)); const payload = (await res.json()) as Webflow.MetaDataMulti<"orders", Webflow.Orders.Order>; allOrders.push(...payload.orders); offset = allOrders.length; if (allOrders.length >= (payload?.pagination?.total ?? 0)) break; } while (opt.forceAll); return allOrders; } async update( wOrderId: string, wOrderUpdate: { comment?: string; shippingProvider?: string; shippingTracking?: string; shippingTrackingURL: string; }, ): Promise { const res = await fetch(`${this.creds.apiSitesUrl}/orders/${wOrderId}`, { method: "PATCH", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify(wOrderUpdate), }); if (!res.ok) throw new WebflowError("Failed to update Webflow order", res.status, await parsePayload(res)); } async fulfill( wOrderId: string, opt: { sendOrderFulfilledEmail?: boolean } = {}, ): Promise { const res = await fetch(`${this.creds.apiSitesUrl}/orders/${wOrderId}/fulfill`, { method: "POST", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify(opt), }); if (!res.ok) throw new WebflowError("Failed to fulfill Webflow order", res.status, await parsePayload(res)); } } class WebhooksClient { constructor(private creds: Credentials) { } async list(opt: PagingOptions = {}): Promise { const allWebhooks: Webflow.Webhook.Config[] = []; let offset = opt.offset ?? 0; do { const res = await fetch(`${this.creds.apiSitesUrl}/webhooks?offset=${offset}&limit=${opt.limit ?? 100}`, { method: "GET", headers: this.creds.authHeader, }); if (!res.ok) throw new WebflowError("Failed to list Webflow webhooks", res.status, await parsePayload(res)); const payload = (await res.json()) as Webflow.MetaDataMulti<"webhooks", Webflow.Webhook.Config>; allWebhooks.push(...payload.webhooks); offset = allWebhooks.length; if (allWebhooks.length >= (payload?.pagination?.total ?? 0)) break; } while (opt.forceAll); return allWebhooks; } /** * Limit of 75 registrations per event */ async create(url: string, event: Webflow.Webhook.Event) { const res = await fetch(`${this.creds.apiSitesUrl}/webhooks`, { method: "POST", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify({ "url": url, "triggerType": event }), }); if (!res.ok) throw new WebflowError("Failed to create Webflow webhook", res.status, await parsePayload(res)); } async remove(webhookId: string): Promise { const res = await fetch( `${this.creds.apiBaseUrl}/webhooks/${webhookId}`, { method: "DELETE", headers: { "Content-Type": "application/json", ...this.creds.authHeader, }, body: JSON.stringify({}), }, ); if (!res.ok) throw new WebflowError("Failed to remove Webflow webhook", res.status, await parsePayload(res)); } } class UtilClient { constructor(private webhookSecret: string) { } verifyWebflowSignature(request: Request, body: unknown): boolean { const timestamp = request.headers.get("x-webflow-timestamp"); if (!timestamp) return false; const providedSignature = request.headers.get("x-webflow-signature"); if (!providedSignature) return false; if (!body) return false; const requestTimestamp = parseInt(timestamp, 10); if (Date.now() - requestTimestamp > 300000) return false; const data = `${requestTimestamp}:${JSON.stringify(body)}`; const hash = crypto .createHmac("sha256", this.webhookSecret) .update(data) .digest("hex"); try { return crypto.timingSafeEqual( Buffer.from(hash, "hex"), Buffer.from(providedSignature, "hex"), ); } catch { return false; } } } export class WebflowClient { readonly Products: ProductsClient; readonly Orders: OrdersClient; readonly Webhooks: WebhooksClient; readonly Util: UtilClient; constructor(options: ClientOptions) { const creds: Credentials = { apiBaseUrl: `https://api.webflow.com/v2`, apiSitesUrl: `https://api.webflow.com/v2/sites/${options.siteId}`, apiCollectionsUrl: `https://api.webflow.com/v2/collections/${options.collectionsId}`, authHeader: { Authorization: `bearer ${options.token}` }, }; this.Products = new ProductsClient(creds); this.Orders = new OrdersClient(creds); this.Webhooks = new WebhooksClient(creds); this.Util = new UtilClient(options.webhookSecret); } }