Files
platform/packages/commerce/src/webflow.ts
T

323 lines
12 KiB
TypeScript

import type { Webflow } from "./util/types";
import { type DeepPartial, type PagingOptions } from "./util/misc";
import crypto from "node:crypto";
const parsePayload = (res: Response): Promise<unknown> =>
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<string, string>;
};
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<Webflow.Products.Skus.Sku>[],
): Promise<string[]> {
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<Webflow.Products.Skus.Sku>,
): Promise<void> {
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<Webflow.Products.ProductAndSku>,
): Promise<string> {
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<Webflow.Products.ProductAndSkus[]> {
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<Webflow.Products.ProductAndSkus | undefined> {
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<Webflow.Products.ProductAndSku>,
): Promise<void> {
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<void> {
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<Webflow.Orders.Order[]> {
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<void> {
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<void> {
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<Webflow.Webhook.Config[]> {
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<void> {
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);
}
}