Skip to Content
ProvidersWebflow

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:

  1. Tenant enters API key and Site ID
  2. Platform calls getCollections() — tenant selects their blog collection
  3. Platform calls getCollectionFields() for the selected collection — shows field list
  4. Tenant maps platform fields (Title, Body, Slug, Excerpt, Featured Image, Date, Categories) to their Webflow field slugs
  5. 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)

TestApproach
createDraftItem() always sets isDraft: trueMock axios.post; assert isDraft === true
createDraftItem() applies fieldMap to build fieldDataAssert keys in request body match fieldMap values
createDraftItem() omits optional fields when not providedAssert no excerpt key in body when not passed
uploadImage() calls metadata endpoint then S3 uploadAssert two axios calls
getCollectionFields() maps required flagMock fields array; assert required: true when validations.required
verify() throws when site not foundMock 404; assert error
verify() throws when id missingMock {}; assert throws

© 2026 Leadmetrics — Internal use only