Webflow
Category: Publishing / CMS
Integration type: Tenant API key or OAuth (stored in integrations table)
External API: Webflow Data API v2
Purpose
Webflow integration enables blog and CMS content publishing for tenants whose websites are built on Webflow. The Blog Writer agent generates content; the Blog Publisher worker pushes it as a draft CMS item to the tenant’s Webflow site.
Like the WordPress integration, the platform always creates items as drafts — publication is done by the DM agency or client within Webflow or via staging approval. Auto-publishing to live is never triggered automatically.
Config Structure
Option A: API key (simpler)
interface WebflowApiKeyConfig {
apiKey: string; // Webflow API token (generated in Webflow → Integrations → API)
siteId: string; // Webflow Site ID (from Site Settings → General)
collectionId: string; // CMS Collection ID for the blog posts collection
}Option B: OAuth (for multi-site agencies)
OAuth is preferred for agencies managing multiple Webflow sites under one account:
scope: cms:read,cms:write,sites:read
Stored in integrations:
provider: 'webflow'
api_key: encrypt(apiKey or access_token)
metadata: {
connectionType: 'api_key' | 'oauth',
siteId: string,
siteName: string,
collectionId: string, // Blog posts collection ID
fieldMap: { // Maps platform content fields to Webflow field slugs
title: 'name',
body: 'body', // Rich text field slug in this Webflow collection
slug: 'slug',
excerpt: 'post-summary',
featuredImage: 'thumbnail',
publishedDate: 'published-date',
categories: 'category',
},
}Why fieldMap?
Webflow CMS collections are custom — every site has different field slugs. A blog collection on one site might call the body field "content" while another uses "body" or "post-body". The fieldMap stored in integrations.metadata maps platform-standard field names to the tenant’s actual Webflow CMS field slugs.
Integration Pattern
Tool layer (packages/tools/src/webflow.ts)
class WebflowTool {
private baseUrl = 'https://api.webflow.com/v2';
constructor(
private apiKey: string,
private siteId: string,
) {}
private headers() {
return {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
async createDraftItem(options: {
collectionId: string;
fieldMap: Record<string, string>; // Platform field → Webflow slug mapping
fields: {
title: string;
body: string; // HTML rich text
slug: string;
excerpt?: string;
featuredImageUrl?: string;
publishedDate?: string; // ISO 8601
categories?: string[];
};
}): Promise<{ itemId: string; adminUrl: string }> {
// Build the Webflow fieldData using the field map
const fieldData: Record<string, any> = {
[options.fieldMap.title ?? 'name']: options.fields.title,
[options.fieldMap.body ?? 'body']: options.fields.body,
[options.fieldMap.slug ?? 'slug']: options.fields.slug,
};
if (options.fields.excerpt && options.fieldMap.excerpt) {
fieldData[options.fieldMap.excerpt] = options.fields.excerpt;
}
if (options.fields.publishedDate && options.fieldMap.publishedDate) {
fieldData[options.fieldMap.publishedDate] = options.fields.publishedDate;
}
if (options.fields.categories?.length && options.fieldMap.categories) {
fieldData[options.fieldMap.categories] = options.fields.categories;
}
const response = await axios.post(
`${this.baseUrl}/collections/${options.collectionId}/items`,
{
fieldData,
isDraft: true, // Always draft
isArchived: false,
},
{ headers: this.headers() },
);
return {
itemId: response.data.id,
adminUrl: `https://webflow.com/dashboard/sites/${this.siteId}/cms/${options.collectionId}/items/${response.data.id}`,
};
}
async uploadImage(options: {
imageBuffer: Buffer;
filename: string;
mimeType: string;
siteAssets?: boolean; // true = upload to Webflow Assets; false = use S3 CDN URL
}): Promise<{ url: string; assetId?: string }> {
// Webflow v2 Assets API: POST /sites/{siteId}/assets
const uploadMetaResponse = await axios.post(
`${this.baseUrl}/sites/${this.siteId}/assets`,
{
fileName: options.filename,
fileHash: require('crypto').createHash('md5').update(options.imageBuffer).digest('hex'),
},
{ headers: this.headers() },
);
const { uploadUrl, uploadDetails, id: assetId } = uploadMetaResponse.data;
// Upload to S3 presigned URL provided by Webflow
const formData = new FormData();
Object.entries(uploadDetails).forEach(([k, v]) => formData.append(k, v as string));
formData.append('file', options.imageBuffer, {
filename: options.filename,
contentType: options.mimeType,
});
await axios.post(uploadUrl, formData);
return {
url: uploadMetaResponse.data.hostedUrl,
assetId,
};
}
async getCollections(): Promise<{ id: string; displayName: string; slug: string }[]> {
const response = await axios.get(
`${this.baseUrl}/sites/${this.siteId}/collections`,
{ headers: this.headers() },
);
return response.data.collections.map((c: any) => ({
id: c.id,
displayName: c.displayName,
slug: c.slug,
}));
}
async getCollectionFields(collectionId: string): Promise<WebflowField[]> {
const response = await axios.get(
`${this.baseUrl}/collections/${collectionId}`,
{ headers: this.headers() },
);
return response.data.fields.map((f: any) => ({
id: f.id,
slug: f.slug,
type: f.type,
required: f.validations?.required ?? false,
}));
}
async verify(): Promise<void> {
const response = await axios.get(`${this.baseUrl}/sites/${this.siteId}`, {
headers: this.headers(),
});
if (!response.data.id) throw new Error('Webflow site not found — check site ID and API key');
}
}Field Mapping Setup Flow
During integration setup in Dashboard → Settings → Integrations → Webflow:
- Tenant enters API key and Site ID
- Platform calls
getCollections()— tenant selects their blog collection - Platform calls
getCollectionFields()for the selected collection — shows field list - Tenant maps platform fields (Title, Body, Slug, Excerpt, Featured Image, Date, Categories) to their Webflow field slugs
- Field map is saved to
integrations.metadata.fieldMap
This one-time setup is required because Webflow collections are fully custom.
Test Cases
Unit tests (packages/tools/src/webflow.test.ts)
| Test | Approach |
|---|---|
createDraftItem() always sets isDraft: true | Mock axios.post; assert isDraft === true |
createDraftItem() applies fieldMap to build fieldData | Assert keys in request body match fieldMap values |
createDraftItem() omits optional fields when not provided | Assert no excerpt key in body when not passed |
uploadImage() calls metadata endpoint then S3 upload | Assert two axios calls |
getCollectionFields() maps required flag | Mock fields array; assert required: true when validations.required |
verify() throws when site not found | Mock 404; assert error |
verify() throws when id missing | Mock {}; assert throws |
Related
- WordPress Provider — alternative CMS publishing
- Blog Writer Agent — generates blog content
- AWS S3 Provider — image storage before Webflow upload
- Tool Integration Layer — integration management