> This page is for Data API, version v2 (default).
> For other versions, use one of these documentation indexes:
> - v2 (default): https://developers.webflow.com/data/v2.0.0/llms.txt
> - v2 Beta: https://developers.webflow.com/data/v2.0.0-beta/llms.txt
> - v1: https://developers.webflow.com/data/v1.0.0/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developers.webflow.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developers.webflow.com/_mcp/server.

# Custom fonts

> How to use the API to upload, update, and delete custom fonts on a Webflow site.

Custom fonts API endpoints let you upload and manage custom fonts on a Webflow site.
It uses eight OAuth v2 endpoints under `/v2/sites/{site_id}/custom_fonts`.

The upload flow uses the same presigned-S3 handshake as the [Assets API](/data/docs/working-with-assets):
Webflow returns signed S3 fields, and you POST the font binary directly to S3.
The Webflow API server never receives the raw font bytes.

## Supported formats

The API accepts the following font formats:

* WOFF2 (`.woff2`)
* WOFF (`.woff`)
* TrueType (`.ttf`)
* OpenType (`.otf`)
* Embedded OpenType (`.eot`)

For security reasons, SVG fonts are not supported.
Sites that have pre-existing SVG fonts continue to return `format: "svg"` in read responses, but you cannot upload new SVG fonts through the API.

## Upload handshake

Uploading a font is a two-step process.

#### Register the font with Webflow

Send a POST request to `/v2/sites/{site_id}/custom_fonts` with the font metadata and the MD5 hash of the binary, as in this example:

```javascript
const { createHash } = require('node:crypto');
const { readFile } = require('node:fs/promises');

const buf = await readFile('./AcmeSans-Regular.woff2');
const fileHash = createHash('md5').update(buf).digest('hex');

const res = await fetch(
  `https://api.webflow.com/v2/sites/${siteId}/custom_fonts`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      fileName: 'AcmeSans-Regular.woff2',
      fileHash,
      fontFamily: 'Acme Sans',
      weight: 400,
      italic: false,
      fontDisplay: 'swap',
    }),
  },
);

const { customFont, upload } = await res.json();
// res.status === 202
// upload.expiresAt gives the URL expiry time (~15 minutes)
```

The API returns `202 Accepted` with a `customFont` object and an `upload` object.
The `upload` object contains a presigned `url` and a `fields` map to use in the next step.

#### Upload the binary to S3

Send a POST request that includes the font binary as `multipart/form-data` to `upload.url`.
Include every key from `upload.fields` as a form field, then append the binary in a field named `file`.

> **Warning**
>
> The `file` field must be the last field in the multipart form.
> Any fields after `file` are ignored.

```javascript
const form = new FormData();
for (const [k, v] of Object.entries(upload.fields)) {
  form.append(k, v);
}
// 'file' must be last
form.append('file', new Blob([buf]), 'AcmeSans-Regular.woff2');

const s3Res = await fetch(upload.url, { method: 'POST', body: form });
// s3Res.status === 201
```

The API returns `201 Created` on success.
The font is immediately available in the site's typography picker and in published-site rendering.

## Batch registration

When you need to register a whole font family at once (for example, Regular, Bold, Italic, and Bold Italic),
send a POST request to `/v2/sites/{site_id}/custom_fonts/batchCreate` with up to 25 fonts in a single `items` array.
This collapses the registration step into one rate-limited request instead of one request per style.

Registration is batched, but the binary uploads are not: the response returns one `upload` object per font, and you
still POST each font binary to its own presigned S3 URL exactly as in the [upload handshake](#upload-handshake) above.

```javascript
const res = await fetch(
  `https://api.webflow.com/v2/sites/${siteId}/custom_fonts/batchCreate`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      items: [
        { fileName: 'AcmeSans-Regular.woff2', fileHash: regularHash, fontFamily: 'Acme Sans', weight: 400, italic: false, fontDisplay: 'swap' },
        { fileName: 'AcmeSans-Bold.woff2', fileHash: boldHash, fontFamily: 'Acme Sans', weight: 700, italic: false, fontDisplay: 'swap' },
      ],
    }),
  },
);

const { created, failed } = await res.json();
// res.status === 200
// Upload each created[i].upload the same way as a single-font upload.
```

The API returns `200 OK` with a per-item manifest. Successfully registered fonts appear in `created`, each with its own
`customFont` and `upload`. If the site's font limit is reached partway through the batch, the fonts that still fit are
registered and the rest appear in `failed` with `name: "FontLimitReached"` — valid fonts are never discarded because a
later font in the batch could not be registered. Each presigned URL expires roughly 15 minutes after issuance, so upload
the binaries promptly.

## Examples

### Foundry or font-platform integration

This example shows how a foundry or font-platform app provisions a licensed font into a user's Webflow site.

```javascript
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';

async function uploadFontToSite({ siteId, accessToken, filePath, metadata }) {
  const buf = await readFile(filePath);
  const fileHash = createHash('md5').update(buf).digest('hex');

  // Step 1 — register with Webflow
  const registerRes = await fetch(
    `https://api.webflow.com/v2/sites/${siteId}/custom_fonts`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        fileName: metadata.fileName,
        fileHash,
        fontFamily: metadata.fontFamily,
        weight: metadata.weight,
        italic: metadata.italic,
        fontDisplay: metadata.fontDisplay ?? 'swap',
        axes: metadata.axes ?? [],
      }),
    },
  );

  if (!registerRes.ok) {
    const err = await registerRes.json();
    throw new Error(`[${err.name}] ${err.msg}`);
  }

  const { customFont, upload } = await registerRes.json();

  // Step 2 — POST the binary directly to S3
  const form = new FormData();
  for (const [k, v] of Object.entries(upload.fields)) form.append(k, v);
  form.append('file', new Blob([buf]), metadata.fileName); // 'file' must be last

  const s3Res = await fetch(upload.url, { method: 'POST', body: form });
  if (s3Res.status !== 201) throw new Error(`S3 upload failed: ${s3Res.status}`);

  return customFont;
}
```

### Multi-site brand font sync

This example shows how an agency dashboard keeps the same brand fonts in sync across every site it manages.

```javascript
async function syncBrandFonts({ targetSiteId, accessToken, brandKit }) {
  const headers = {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  };

  // List the target site's existing fonts
  const listRes = await fetch(
    `https://api.webflow.com/v2/sites/${targetSiteId}/custom_fonts`,
    { headers },
  );
  const { customFonts: existing } = await listRes.json();

  // Identify fonts on the target that are not in the brand kit
  const brandFamilies = new Set(brandKit.map((f) => f.fontFamily));
  const toRemove = existing
    .filter((f) => !brandFamilies.has(f.fontFamily))
    .map((f) => ({ id: f.id }));

  // Bulk-delete the non-brand fonts
  if (toRemove.length > 0) {
    const delRes = await fetch(
      `https://api.webflow.com/v2/sites/${targetSiteId}/custom_fonts/batchDelete`,
      { method: 'POST', headers, body: JSON.stringify({ items: toRemove }) },
    );
    const { deleted, failed } = await delRes.json();
    console.log(`Removed ${deleted.length}; ${failed.length} could not be removed.`);
  }

  // Upload missing brand fonts
  const existingFamilies = new Set(existing.map((f) => f.fontFamily));
  for (const font of brandKit) {
    if (existingFamilies.has(font.fontFamily)) continue;
    await uploadFontToSite({
      siteId: targetSiteId,
      accessToken,
      filePath: font.localPath,
      metadata: font,
    });
  }
}
```

### AI or MCP agent typography setup

This example shows how an AI agent uploads a brand font and verifies it is accessible before proceeding to dependent steps.

```javascript
async function agentSetupTypography({ siteId, accessToken, brandFontPath, brandFontMeta }) {
  // 1. Upload the brand font
  const customFont = await uploadFontToSite({
    siteId,
    accessToken,
    filePath: brandFontPath,
    metadata: brandFontMeta,
  });

  // 2. Verify that the font is reachable through the API before downstream steps
  const verifyRes = await fetch(
    `https://api.webflow.com/v2/sites/${siteId}/custom_fonts/${customFont.id}`,
    { headers: { Authorization: `Bearer ${accessToken}` } },
  );
  if (!verifyRes.ok) {
    throw new Error('Font upload completed but resource is not yet readable.');
  }

  return customFont;
}
```

## Limitations

* **Supported formats:** WOFF2, WOFF, TTF, OTF, EOT. SVG fonts are not accepted.
* **File size:** Uploads exceeding 4 MB are rejected by S3 with an `EntityTooLarge` error.
* **Per-site limit:** Each site has an internal cap on total custom fonts. Exceeding the cap returns `409 Conflict`.
* **Variable font axes:** Each font supports a maximum of 16 variable axes.
* **Batch create:** A single batch create request supports 1–25 fonts. Binary uploads remain per-font.
* **Batch delete:** A single batch delete request supports 1–100 font IDs.