# Webfaze Extension Developer Guide

Webfaze team, October 2026

Everything you need to build an extension that works on every Webfaze site, from your first file to a listing that site owners can install. Written for Webfaze 1.0.3.2. This guide is the complete reference: there is no separate SDK, and no capabilities beyond the ones described here.

## 1. In one minute

A Webfaze extension is **one JavaScript file**: a JSON manifest at the top that says what the extension is and what it needs, followed by functions (handlers) that Webfaze calls when something happens on the site, such as a post being published or a form being sent. Your code runs in a locked sandbox on the site's server and reaches the site only through one object, `wf`, which checks every call against the permissions in your manifest.

| Your extension can | Your extension cannot |
| --- | --- |
| React when pages and posts are created, saved, published, unpublished or deleted | Add widgets or blocks to the page builder |
| React when a visitor sends a form | Add pages to the public site or change how pages look |
| Run on a timer, from every 5 minutes to once a week | Add screens or menu items to the Console |
| Read published content, create drafts, change titles and summaries | Publish anything: a person always decides |
| Call the HTTPS services you declared (Slack, Discord, a CRM, your own API) | Read users, passwords, sessions or files on the server |
| Keep its own small data store | Load npm packages or Node modules, run programs, open raw network connections |
| Ask the site owner for settings such as an API key (stored encrypted) | Read site settings whose name contains pass, secret, key or token |

Three rules shape everything else:

1. **Nothing undeclared works.** If your manifest does not ask to read `articles`, a call to read articles fails. Site owners see your permissions in plain words before they install.
2. **Extensions never slow the site down.** Handlers run after the action that triggered them, with a time and memory budget. A failure is written to the extension's activity log; visitors never see it.
3. **Every extension reaches sites through the webfaze.com directory.** You publish it on webfaze.com, Webfaze reviews and signs it, and site owners install it from the Extensions screen in their Console. Sites do not install files by hand; only a developer's test site can, in developer mode (section 3).

Webfaze's own official extensions (Commerce, Bookings, SEO Toolkit and others) are built into Webfaze itself and switched on from the directory. They do not use extra powers that are hidden from you: third-party extensions use exactly the API in this guide.

## 2. From idea to listing

Every extension goes through the same eight steps:

1. **Check it fits.** Compare your idea with the table above. If it needs something extensions cannot do, read section 18 before you start.
2. **Set up a test site** with Webfaze 1.0.3.2 or newer in developer mode (section 3).
3. **Write the file:** manifest plus handlers (sections 4 to 11).
4. **Install it on your test site** from the Developer mode box on the Extensions screen and try every event (section 4).
5. **Run the checklist** in section 13.
6. **Publish it on webfaze.com:** create your listing, add images and text, upload the file (section 14).
7. **Pass review.** The automatic checks run at once; a person at Webfaze reviews it, usually within 5 working days (section 15).
8. **Site owners install it** from Extensions in their Console. You ship improvements by uploading a higher version, which sites are offered as an update (sections 15 and 17).

## 3. Set up your development site

You need:

- **Node.js 22.15 or newer** on the machine that runs your test site. Older Node versions cannot run the sandbox, and the Extensions screen says so.
- **Webfaze 1.0.3.2 or newer** (`webfaze.cjs`), downloaded from webfaze.com, Account, Downloads.
- **An active Webfaze licence** on that test site. Installing any extension, even your own, asks webfaze.com first. Test addresses such as `localhost`, `*.test` and `staging.*` do not use up one of your licence's sites.

Start the test site in **developer mode** by setting one environment variable:

```bash
# first time only: set up the site
node webfaze.cjs install
# then start it in developer mode
WEBFAZE_DEV_EXTENSIONS=1 node webfaze.cjs start
```

On Windows PowerShell: `$env:WEBFAZE_DEV_EXTENSIONS='1'; node webfaze.cjs start`.

Developer mode adds a **Developer mode** box at the bottom of the Extensions screen, where you paste your file to install it, and a **Run now** button in each installed extension's details, which runs it once with a sample event. Site owners never see either: on a normal site, extensions only come from the directory.

To get a working starter file, run `node webfaze.cjs extension my-extension` in any folder. It writes `my-extension.js`, which installs as it is.

## 4. Tutorial: your first extension

You will build **Discord announcements**: when an article is published, it posts the title and link to a Discord channel. It takes about 20 minutes.

**Step 1. Create the file.** Save this as `discord-announce.js`:

```javascript
/* webfaze
{
  "name": "discord-announce",
  "version": "1.0.0",
  "label": "Discord announcements",
  "description": "Posts each newly published article to a Discord channel, with its title and link.",
  "author": "Your name",
  "events": ["record.published"],
  "capabilities": {
    "content": { "read": ["articles"] },
    "net": { "egress": ["discord.com"] },
    "settings": true
  },
  "config": [
    { "key": "webhook_url", "label": "Discord webhook address", "type": "secret", "required": true,
      "help": "In Discord: channel settings, Integrations, Webhooks, Copy webhook URL." },
    { "key": "message", "label": "Message before the link", "type": "text",
      "help": "For example: New on the blog:" }
  ]
}
*/

exports['record.published'] = async (event, wf) => {
  // Only articles: pages are published too, and wf.content.get returns null for them here.
  const article = await wf.content.get('articles', event.handle);
  if (!article) return 'skipped: not an article';

  const hook = String((await wf.config.get('webhook_url')) || '');
  if (!hook.startsWith('https://discord.com/api/webhooks/')) return 'not set up: add the Discord webhook address in settings';

  const site = String((await wf.settings.get('site_url')) || '').replace(/\/+$/, '');
  const intro = (await wf.config.get('message')) || 'New on the blog:';
  const link = site + '/blog/' + article.handle;

  try {
    const res = await wf.net.fetch(hook, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ content: intro + ' **' + article.title + '** ' + link }),
    });
    return res.ok ? 'posted: ' + article.title : 'Discord answered ' + res.status;
  } catch (e) {
    // A network problem should explain itself in the activity log.
    return 'could not reach Discord: ' + e.message;
  }
};
```

**Step 2. Install it on your test site.** Open Extensions, scroll to **Developer mode**, paste the whole file and press **Review this file**. You see the permissions screen site owners will see: read articles, send data to discord.com, read site settings. Fill in the webhook address and press **Add and enable**.

**Step 3. Try it.** Open the extension (click its card in the Installed tab), press **Run now**. It runs `record.published` with a sample article whose handle is `example-article`; since that article does not exist, the activity shows *skipped: not an article*, which proves the handler ran. Now publish a real article and open **Activity**: you should see *posted: (the title)*, and the message in Discord.

**Step 4. Break it on purpose.** Clear the webhook setting and publish again: the activity should say *not set up*. Turn off the network (or use a wrong address) and check the error is readable. Good extensions explain every outcome in their return value.

**Step 5. Publish it.** Run the checklist in section 13, then follow section 14.

Tip: the blog address prefix can be changed by the site owner (Settings, Article URL prefix). If links matter, read `wf.settings.get('article_prefix')` instead of assuming `/blog`.

## 5. Anatomy of an extension file

An extension file has exactly two parts, in this order:

```javascript
/* webfaze
{ ...the manifest, as JSON... }
*/

// ...your code...
exports['record.published'] = async (event, wf) => { /* ... */ };
```

- **The manifest block** must be the very first thing in the file: `/* webfaze`, then valid JSON (double quotes, no trailing commas, no comments), then `*/`.
- **Your code** is plain JavaScript (ES2022). It is loaded fresh for every run, so top-level variables do not survive between runs; keep anything you need in `wf.data` (section 10).
- **Handlers** are exported under the exact event name: `exports['record.published'] = ...` or `module.exports = { 'schedule.tick': ... }`.
- **A handler** is an `async` function that receives `(event, wf)`. What it returns (a short string is best) is shown in the extension's activity log. If it throws, the run is marked failed with your error message.
- **One file only.** You can split your work into several files while developing and bundle them into one (section 12), but no `require` or `import` may remain in the file you upload.
- **Size:** up to 512 KB of code, 600 KB for the whole file.

## 6. Manifest reference

| Field | Required | What to put |
| --- | --- | --- |
| `name` | Yes | The extension's permanent id: lowercase letters, numbers and `-`, 3 to 40 characters, starting with a letter, for example `discord-announce`. It must be exactly the name of your listing on webfaze.com and can never change. Names starting with `webfaze` are reserved. |
| `version` | Yes | Three numbers, `1.0.0`. Every upload must be higher than the last approved one (section 15). |
| `label` | Yes | The name owners see, up to 80 characters. |
| `description` | Yes | One or two plain sentences for the permissions screen, up to 300 characters. Say what it does and why it needs each permission. |
| `author` | No | Up to 80 characters. Without it, your developer name on webfaze.com is shown. |
| `events` | No | Which events to receive (section 8), up to 20. An unknown name is refused. |
| `everyMinutes` | No | Run on a timer, every 5 to 10080 minutes (one week). Export a `schedule.tick` handler. |
| `capabilities` | No | What the extension may do (section 7). Leave out anything you do not use. |
| `budget` | No | `{ "cpuMs": 3000, "memoryMb": 64 }` are the defaults; the maximums are 15000 and 256. Ask for more only if you need it: reviewers question anything over 5000 ms. |
| `config` | No | Up to 12 settings the site owner fills in (section 10). |

An extension that uses everything looks like this:

```json
{
  "name": "order-notes",
  "version": "1.2.0",
  "label": "Order notes to a sheet",
  "description": "Copies each contact form message to your spreadsheet service and keeps a weekly count.",
  "author": "Example Ltd",
  "events": ["submission.received", "schedule.tick"],
  "everyMinutes": 10080,
  "capabilities": {
    "content": { "read": ["pages"], "write": [] },
    "net": { "egress": ["api.example-sheets.com"] },
    "settings": true
  },
  "budget": { "cpuMs": 4000, "memoryMb": 64 },
  "config": [
    { "key": "api_key", "label": "Your sheet service key", "type": "secret", "required": true },
    { "key": "sheet", "label": "Sheet name", "type": "text", "help": "As it appears in your account." }
  ]
}
```

## 7. Permissions and what site owners see

Permissions live in `capabilities`. Ask for the least you need: owners read the list before installing, and reviewers check every item is used.

| Capability | Example | Allows | Owners see |
| --- | --- | --- | --- |
| `content.read` | `["articles", "pages"]` | `wf.content.list` and `wf.content.get` on those collections (published items only) | Read: articles, pages |
| `content.read` with `*` | `["*"]` | Reading every collection | Read all of your content |
| `content.write` | `["articles"]` | `wf.content.create` (drafts) and `wf.content.update` (title, summary) | Create and change: articles |
| `content.write` with `*` | `["*"]` | Writing to every collection | Change any of your content (shown in red) |
| `net.egress` | `["hooks.slack.com"]` | `wf.net.fetch` to exactly these hosts, up to 10 | Send data to: hooks.slack.com (more than two hosts is shown in red) |
| `settings` | `true` | `wf.settings.get` | Read your site settings |

Every extension also may store its own data and is listed as running on a timer when `everyMinutes` is set. Owners also see what it is **denied**: users and passwords, files on the server, running programs, and anything you did not ask for.

Collection names are the handles of the site's collections: `pages`, `articles`, and any custom collection the owner created (its handle is the last part of its address in the Console: recipes in /admin/content?c=recipes). Your extension should cope when a collection does not exist on a site: reads return nothing.

## 8. Events

List the events you want in `events` and export a handler for each, named exactly after the event.

| Event | Fires when |
| --- | --- |
| `record.created` | A page, article or custom item is created |
| `record.updated` | One is saved |
| `record.published` | One becomes published: by hand, by scheduled publishing, or by restoring a published item from the trash |
| `record.unpublished` | A published item goes back to draft or to the trash |
| `record.deleted` | One is deleted for good |
| `submission.received` | A visitor sends a form on the site |
| `schedule.tick` | Your `everyMinutes` interval has passed |

What `event` contains, with real examples:

```javascript
// record.created, record.updated, record.published, record.unpublished
{ id: 'b6f1c0a2-...', title: 'How to brew pour-over', handle: 'pour-over-guide',
  status: 'published', summary: 'A step-by-step guide...', publishedAt: 1792234800000 }

// record.deleted
{ id: 'b6f1c0a2-...' }

// submission.received (data holds the form's fields by name)
{ form: 'contact', data: { name: 'Sara', email: 'sara@example.com', message: 'Hello' }, at: 1792234800000 }

// schedule.tick
{ at: 1792234800000 }
```

Plan for these facts:

- **Record events do not say which collection** the item belongs to. Ask for it: `wf.content.get('articles', event.handle)` returns the article, or `null` when it is a page (then try `'pages'`).
- `publishedAt` and `at` are milliseconds since 1970 (`new Date(at)`). `summary` and `publishedAt` can be `null`.
- **Handlers run after the action**, never during it: they cannot stop or change a publish, and a slow handler never slows the site.
- **A site runs at most two extension jobs at once** and skips events that arrive while it is busy. Write handlers so running twice does no harm, and do not rely on seeing every event; for work that must catch up, add a `schedule.tick` handler that checks what was missed.
- `submission.received` includes spam that the site's spam filter flagged; check the content if it matters to you.

## 9. The wf API

`wf` is the second argument of every handler and your only way to reach the site. Every method is `async`. A method you have no permission for throws an error that says so.

| Method | Needs | Returns and limits |
| --- | --- | --- |
| `wf.content.list(collection, { limit })` | `content.read` | Published items, newest first. `limit` 1 to 100, default 20. |
| `wf.content.get(collection, handle)` | `content.read` | One published item by its address name, or `null`. |
| `wf.content.create(collection, { title, handle, summary, blocks })` | `content.write` | Creates a **draft** and returns `{ id, handle }`. Title up to 300 characters, summary 600, up to 200 blocks. |
| `wf.content.update(id, { title, summary })` | `content.write` on that item's collection | Changes only the title and summary; returns `{ changed }`. Status can never be changed. |
| `wf.net.fetch(url, { method, headers, body })` | `net.egress` with that host | `{ status, ok, body }`; `body` is text. Rules in section 10. |
| `wf.settings.get(key)` | `settings: true` | A site setting, or `null`. |
| `wf.config.get(key)` | nothing | A value the owner entered in your settings, or `null`. |
| `wf.data.get(key)`, `.set(key, value)`, `.remove(key)`, `.keys()` | nothing | Your private store (section 10). |
| `wf.log(...parts)` | nothing | Adds a line to the activity log (up to 2,000 characters). `console.log`, `info`, `warn` and `error` do the same. |

An item from `content.list` or `content.get` looks like this:

```javascript
{ id: 'b6f1c0a2-...', title: 'How to brew pour-over', handle: 'pour-over-guide',
  summary: 'A step-by-step guide...', publishedAt: 1792234800000,
  blocks: [ { type: 'richtext', props: { html: '<p>Pour-over coffee is...</p>' } } ] }
```

`blocks` is the page content as Webfaze stores it. Pages built with the page builder keep their content elsewhere and return an empty `blocks` list.

Useful site settings for `wf.settings.get`:

| Key | Value |
| --- | --- |
| `site_url` | The site's address, such as `https://example.com` (no trailing slash) |
| `site_name` | The site's name |
| `article_prefix` | The word before post addresses, such as `blog` (empty means posts live at the top level) |
| `site_language` | The site's language code, such as `en`, `fr` or `ar` |
| `timezone` | The site's time zone, such as `Europe/London` |

Examples:

```javascript
// The five newest articles
const latest = await wf.content.list('articles', { limit: 5 });

// A draft for a person to review and publish
const draft = await wf.content.create('articles', {
  title: 'Weekly roundup',
  summary: 'What happened this week.',
  blocks: [{ type: 'richtext', props: { html: '<p>Written by the roundup extension.</p>' } }],
});

// Fill in a missing summary
if (!event.summary) await wf.content.update(event.id, { summary: 'A short description.' });
```

## 10. Settings, storage and calling other services

### Settings the owner fills in

Declare them in `config`. The owner fills them in when installing, and later in the extension's details on the Extensions screen.

| Field | Rules |
| --- | --- |
| `key` | Lowercase letters, numbers and `_`, up to 40, starting with a letter. You read it with `wf.config.get(key)`. |
| `label` | What the owner sees, up to 80 characters. |
| `type` | `text`, `secret` (hidden after saving, for keys and tokens), `url`, `number` or `boolean` (a tick box). |
| `help` | One line under the field, up to 200 characters: tell the owner exactly where to find the value. |
| `required` | `true` if the extension cannot work without it. Still check for it in your code. |

All values are stored encrypted on the site. **Never put a key or token in your code**: webfaze.com refuses uploads that contain one.

### Your own storage

`wf.data` is a small key-value store that belongs to one installation of your extension. It survives between runs and is deleted when the owner removes the extension.

```javascript
const sent = (await wf.data.get('sent')) || [];        // any JSON value, or null
await wf.data.set('sent', [...sent, event.id].slice(-200)); // keep it trimmed
await wf.data.remove('old-key');
const keys = await wf.data.keys();
```

Limits: 500 keys per installation, keys up to 160 characters, each value up to 256 KB.

### Calling other services

`wf.net.fetch` is the only network access. The rules:

- `https://` only, and only to hosts listed in `capabilities.net.egress`, written exactly (`api.example.com`, not `*.example.com`). Addresses that resolve to private or local networks are refused.
- Methods GET, POST, PUT, PATCH and DELETE. The request body is a string of up to 256 KB: use `JSON.stringify`.
- **The `Authorization`, `Cookie` and `Host` headers are removed.** Choose services that accept their key another way: a webhook address with the token in it (Slack, Discord, Teams, Zapier, Make), a key header such as `x-api-key` or `api-key`, or a key in the address. Services that only accept `Authorization: Bearer` cannot be called today (see section 18).
- Redirects are not followed, a request times out after 8 seconds, and only the first 256 KB of the answer is read.
- Each installation may receive about 5 MB per hour.

```javascript
const res = await wf.net.fetch('https://api.example.com/v1/notes', {
  method: 'POST',
  headers: { 'content-type': 'application/json', 'x-api-key': await wf.config.get('api_key') },
  body: JSON.stringify({ text: event.title }),
});
if (!res.ok) return 'the service answered ' + res.status;
const answer = JSON.parse(res.body);
```

## 11. Limits and the code check

| Limit | Value |
| --- | --- |
| Time per run | Default 3 seconds, up to 15 with `budget.cpuMs`. It is wall-clock time, so waiting for the network counts. |
| Memory per run | Default 64 MB, up to 256 MB with `budget.memoryMb` |
| `wf` calls per run | 200 |
| File size | 512 KB of code, 600 KB in total |
| Activity log | The last 50 runs are kept |

The same automatic check runs when you upload to webfaze.com and when a site installs your file. It **refuses** a file containing any of these, anywhere:

- `eval(`, `new Function(` or `import(`
- `require(`, and in particular the system modules `fs`, `child_process`, `net`, `http`, `https`, `http2`, `tls`, `dgram`, `dns`, `vm`, `module`, `cluster`, `worker_threads`, `inspector`, `repl` and `v8`
- `process.binding`, `process.dlopen`, `process.exit`
- `globalThis[` (computed access) and `constructor['constructor']`

webfaze.com also refuses: something that looks like a secret (AWS, Stripe live, GitHub, Slack or Google keys, private keys), sending data to tracking services (Google Analytics, Tag Manager, DoubleClick, Facebook, Hotjar, Segment, Mixpanel, Clarity) or to webfaze.com, a name that does not match your listing, and a version that is not higher than the last one.

It **warns**, and reviewers will ask why: `while (true)`, `setInterval` (it does not last between runs: use `everyMinutes`), an encoded string of 600 or more characters, write access to `*`, and more than 5000 ms of time.

The check reads your file as text, line by line, and only skips `//` comments. The words `eval(` or `import(` inside a string or a `/* */` comment fail too, so reword them.

Inside the sandbox there is no `fetch`, `XMLHttpRequest`, `WebSocket`, `WebAssembly`, environment, file system or standard output. Everything else in plain JavaScript works: `Date`, `JSON`, `URL`, `Math`, `Intl`, regular expressions, `setTimeout` within the time budget, classes and async functions.

## 12. Writing in TypeScript

Most extensions are best as one plain JavaScript file. For bigger ones you can write TypeScript in several files and bundle them into one. A project looks like this:

```text
my-extension/
  manifest.json        the manifest (the JSON part of the header)
  webfaze.d.ts         types for wf and the events (below)
  src/index.ts         your handlers
  src/lib/*.ts         your helpers (no npm packages that use Node modules)
  build.mjs            makes dist/<name>.js (below)
```

`webfaze.d.ts`:

```typescript
export interface RecordEvent { id: string; title: string; handle: string; status: string; summary: string | null; publishedAt: number | null }
export interface DeletedEvent { id: string }
export interface SubmissionEvent { form: string; data: Record<string, string>; at: number }
export interface TickEvent { at: number }
export interface ContentItem { id: string; title: string; handle: string; summary: string | null; publishedAt: number | null; blocks: unknown[] }
export interface FetchResult { status: number; ok: boolean; body: string }

export interface Wf {
  content: {
    list(collection: string, opts?: { limit?: number }): Promise<ContentItem[]>;
    get(collection: string, handle: string): Promise<ContentItem | null>;
    create(collection: string, data: { title: string; handle?: string; summary?: string; blocks?: unknown[] }): Promise<{ id: string; handle: string }>;
    update(id: string, data: { title?: string; summary?: string }): Promise<{ changed: boolean }>;
  };
  net: { fetch(url: string, options?: { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; headers?: Record<string, string>; body?: string }): Promise<FetchResult> };
  settings: { get(key: string): Promise<string | null> };
  config: { get(key: string): Promise<string | number | boolean | null> };
  data: { get<T = unknown>(key: string): Promise<T | null>; set(key: string, value: unknown): Promise<true>; remove(key: string): Promise<true>; keys(): Promise<string[]> };
  log(...parts: unknown[]): Promise<null>;
}

export type Handler<E> = (event: E, wf: Wf) => Promise<unknown>;
```

`src/index.ts`, exporting handlers under the event names:

```typescript
import type { Handler, RecordEvent } from '../webfaze.d.ts';

const onPublish: Handler<RecordEvent> = async (event, wf) => {
  await wf.log('published', event.title);
  return 'ok';
};

export { onPublish as 'record.published' };
```

`build.mjs` (run `npm i -D esbuild` once, then `node build.mjs`; it works on Windows, macOS and Linux):

```javascript
import { build } from 'esbuild';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';

const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const result = await build({
  entryPoints: ['src/index.ts'], bundle: true, format: 'cjs',
  platform: 'neutral', target: 'es2022', write: false, minify: false,
});
const code = result.outputFiles[0].text;
for (const bad of ['require(', 'import(', 'eval(']) {
  if (code.includes(bad)) throw new Error(`The bundle contains ${bad}, which Webfaze refuses`);
}
mkdirSync('dist', { recursive: true });
writeFileSync(`dist/${manifest.name}.js`, `/* webfaze\n${JSON.stringify(manifest, null, 2)}\n*/\n${code}`);
console.log(`Wrote dist/${manifest.name}.js`);
```

Do not minify: reviewers must be able to read your code. If a library you bundle needs a Node module, the build stops with an error; choose a library without one.

## 13. Checklist before you publish

- [ ] The manifest `name` is the name you will give your listing, and `version` is higher than your last upload.
- [ ] `description` says in plain words what the extension does and why it needs each permission.
- [ ] Every permission is used, and none is wider than needed (named collections, not `*`; exact hosts).
- [ ] No keys, tokens or passwords in the code; everything the owner provides is a `config` field with clear `help`.
- [ ] Every handler returns a short message for every outcome, including "not set up yet" and network errors.
- [ ] Running a handler twice for the same event does no harm.
- [ ] It works on a site where a collection, setting or network service is missing.
- [ ] It installs on your test site in developer mode with no refusals, and with no warnings you cannot explain.
- [ ] You tested every event it lists on a real test site, and the activity log reads well.
- [ ] The code is readable, not minified, under 600 KB.
- [ ] You have a logo, a banner, 1 to 10 screenshots, a description, an FAQ and installation notes ready (section 14).

## 14. Publishing on webfaze.com

There are two ways to publish: one zip that fills in everything (below), or the forms, step by step (further down). Both run the same checks and the same review.

### Publish with one zip

Steps 1 and 2 below (an account with two-step sign-in, then Become a developer) come first. Then, in **Account, My extensions**, use **Upload a package (.zip)**. If the zip is laid out as described here, the listing is created, its text, logo, banner and screenshots are filled in, and the bundle is submitted as a version, all in one upload. To publish an update, upload a new zip with a higher version number, from My extensions or from the listing's **Versions** card (**Upload a new package**).

The files sit at the top of the zip, or inside one top-level folder:

```text
my-extension.zip
  extension.js          the bundle, with the /* webfaze { ... } */ manifest   required
  listing.json          the listing text                                     optional
  logo.png              or logo.jpg, logo.webp, logo.svg                     optional
  banner.png            or banner.jpg, banner.webp                           optional
  screenshots/
    01.png              PNG, JPG or WebP, used in file-name order, up to 10  optional
    02.png
```

- **The bundle** is `extension.js`, or the only `.js` file at the top of the zip (for example `my-extension-1.2.0.js`). The manifest `name` decides the listing: a new name creates a listing, a name you already own updates that listing, and a name that belongs to someone else is refused.
- **listing.json** holds any of these fields; a field you leave out is left as it is (or, for a new listing, empty):

```json
{
  "name": "My Extension",
  "short": "One sentence for cards and search, 10 to 150 characters.",
  "description": "Plain text, up to 8,000 characters.

- Bullets start with a dash",
  "faq": "Question?
Answer.",
  "installation": "Install it from Extensions, Add new.",
  "category": "seo",
  "tags": ["sitemap", "meta tags"],
  "homepage": "https://example.com",
  "licence": "MIT",
  "minWebfaze": "1.0.3.2",
  "changelog": "What changed in this version.",
  "captions": ["What the first screenshot shows", "What the second shows"]
}
```

| Field | Rules |
| --- | --- |
| `name` | The display name, 3 to 80 characters. Without it, a new listing uses the manifest `label`. |
| `short` | 10 to 150 characters. Without it, a new listing uses the manifest `description` if that fits. |
| `description`, `faq`, `installation` | Plain text, up to 8,000, 4,000 and 4,000 characters. Use 
 for a new line. |
| `category` | One of `content`, `seo`, `forms`, `marketing`, `analytics`, `notify`, `ai`, `ecommerce`, `social`, `maintenance`, `other`. A new listing without one is put in `other`. |
| `tags` | A list of words, or one text with commas. Lowercase letters, numbers, spaces and dashes, up to 200 characters in all. |
| `homepage` | An `https://` address. |
| `licence` | One of `MIT`, `Apache-2.0`, `GPL-3.0-or-later`, `BSD-3-Clause`, `MPL-2.0`, `Proprietary`. A new listing without one gets `MIT`. |
| `minWebfaze` | The oldest Webfaze you tested on, like `1.0.3.2`. Default: the newest Webfaze release. |
| `changelog` | What changed in this version, at least 3 characters. Needed for every version after the first (the first defaults to "First release."). The **What changed** box next to the upload overrides it. |
| `captions` | One per screenshot, matched to the screenshots in file-name order, up to 160 characters each. |

- **Images** follow the same rules as the forms (table below). The logo and banner replace the current ones. If the zip has screenshots, they replace all current screenshots; if it has none, the current ones stay. A screenshot smaller than 640 by 360 is skipped with a note instead of failing the upload.
- **Limits:** the zip may be up to 10 MB, hold at most 200 entries and 25 MB uncompressed; the bundle up to 600 KB, the logo 1 MB, the banner 3 MB and each screenshot 4 MB. Encrypted zips, zip64, absolute paths, paths with `..` and backslash separators are refused. Make the zip with any ordinary zip tool.
- **Anything else** in the zip (a README, source files, `mobile.png`) is ignored, and the result says so.

Everything is checked before anything is saved: if the zip, a listing field, an image or the name is wrong, nothing changes and you see every problem at once. If the bundle fails the automatic checks, the listing and images are still saved and the version is kept with its report under Versions, exactly as with a manual upload; fix it, raise the version and upload again. Uploading the same version number again updates the listing and images without submitting a new version. After the upload you land on the listing page, which says what was created or updated, which images were saved, whether the version is waiting for review, and any skipped files.

### Publish with the forms

1. **Create an account** on webfaze.com and turn on two-step sign-in in **Account, Security**. Every developer account needs it, and uploads are refused without it.
2. **Become a developer:** in your account menu choose **Become a developer** (or go to webfaze.com/account/developer) and accept the developer terms and review guidelines (webfaze.com/developers/guidelines). Your licences, downloads and orders stay; you gain My extensions, Earnings and this guide.
3. **Create the listing:** **Account, My extensions, New extension**. Enter the display name, the name (exactly your manifest `name`; it can never change), a short description and a category.
4. **Fill in the listing and add images** (table below).
5. **Upload a version** under **Versions**: choose your `.js` file, set **Works with Webfaze from** to the oldest Webfaze you tested on (for example `1.0.3.2`), and write what changed.
6. **Read the automatic report.** Problems are marked **Blocks** (must fix), **Check** (a reviewer will ask) or **Note**. Fix, raise the version, upload again.
7. **Wait for review** (section 15). You are emailed the result.

| Listing field | Rules |
| --- | --- |
| Display name | 3 to 80 characters |
| Name | Your manifest `name`; permanent |
| Short description | 10 to 150 characters; shown on cards and in search |
| Description | Up to 8,000 characters of plain text. A blank line starts a new paragraph. Lines starting with a dash and a space become bullets; put a blank line between a heading line such as "Features:" and its bullets. |
| Category | Content, SEO, Forms, Marketing, Analytics, Notifications, AI, E-commerce, Social, Maintenance or Other |
| Licence | MIT, Apache-2.0, GPL-3.0-or-later, BSD-3-Clause, MPL-2.0 or Proprietary |
| Tags | Lowercase words separated by commas, up to 200 characters |
| Homepage | An `https://` address (optional) |
| FAQ, installation notes | Plain text, up to 4,000 characters each |
| Logo | Square (within 10%), at least 128 by 128 px (256 by 256 or larger recommended), PNG, JPG, WebP or SVG, up to 1 MB |
| Banner | At least 772 by 250 px, 1544 by 500 recommended, PNG, JPG or WebP, up to 3 MB. Keep important text away from the edges. |
| Screenshots | 1 to 10, at least 640 by 360 (1280 by 800 or larger recommended), PNG, JPG or WebP, up to 4 MB each, each with a caption. Real screenshots of your extension only. |

Images are converted to WebP. On a listing that is already live, new images appear after a reviewer approves them; text changes appear straight away.

## 15. Review, updates and pulled versions

A person on the Webfaze team reads every version: usually within **5 working days** for a new extension and **2** for an update. Updates that ask for no new permissions are quickest. Reviewers check the developer guidelines:

1. Do what the listing says, and nothing else.
2. Ask only for the permissions you use, and explain each one in the description.
3. No tracking of site visitors, no adverts injected into sites, and no data sent anywhere the owner was not told about.
4. Readable code, not minified or obfuscated beyond normal bundling.
5. No secrets in the code.
6. At most one notice, on your extension's own settings.
7. A free version stays usable; a paid upgrade may add features but must not cripple it.
8. Keep a support channel open (your listing's Support tab counts) and answer in reasonable time.
9. Fix security problems quickly.
10. Honest listings: real screenshots, no fake ratings, no other brands' logos or misleading names.

| Version state | Meaning and what to do |
| --- | --- |
| Failed checks | The automatic checks refused it. Read the report, fix, upload a higher version. |
| In review | Waiting for a reviewer. Nothing to do. |
| Changes asked | The reviewer's note says what to change. Upload a new version. |
| Approved | Signed and offered to sites. |
| Rejected | Not published. You can reply to the decision. |
| Pulled | Removed for a problem, and switched off on every site that runs it. Fix it and upload a new version. |

Updating: raise `version` (each number can be used once), upload, and pass review. Sites see **Update available** on your card; an update that asks for new permissions is marked so, and the owner agrees to the new list before updating. Sites older than your **Works with Webfaze from** version are not offered it.

## 16. Selling a paid extension

Choose the billing and the price on your listing under **Price**:

- **Free:** anyone with a Webfaze licence can install it.
- **One-time:** one payment covers every site the buyer's Webfaze licences run, including future versions.
- **Monthly** or **Yearly:** a subscription. The buyer has the extension on every site their licences run while the subscription is paid up (a cancelled subscription keeps working until the end of the period paid for). When it ends, their sites switch the extension off at their next update check, with a message saying the subscription ended; subscribing again turns it back on. You earn again at every renewal.

A paid price, or a change of billing, takes effect once the Webfaze team approves it. Subscribers keep the price they signed up at. That is all you do: Webfaze handles the payment (through Lemon Squeezy, its reseller), the receipt, renewals and sales tax. You don't need a payment account or a product of your own.

| Term | Current value |
| --- | --- |
| Price | One-time $2 to $500, monthly $1 to $100, yearly $5 to $1,000; US dollars only |
| Your share | 80% of every payment (each sale and each renewal) before tax; Webfaze keeps 20% and pays the payment fees (your price form shows the current share) |
| Refunds | A refunded sale or renewal takes its share back |
| When earnings can be paid | 14 days after each sale or renewal (the refund window) |
| Minimum payout | $25 |
| How payouts are sent | By the Webfaze team, to the payout method in Account, Earnings |

An extension listed for free stays free, so everyone who installed it keeps getting updates. To sell a paid edition, publish it as a separate extension.

## 17. How site owners find and install your extension

Once approved, your extension appears on webfaze.com/extensions and inside every Webfaze site's Console:

1. The owner opens **Extensions**. They see a search bar, category and sort filters, and a card for every extension: your **banner**, **logo**, name, "by" your developer name, **short description**, rating, number of sites, and the price or Free.
2. Clicking your card opens a **popup** with the banner, the full description, your screenshots with captions, the permissions, the changelog, the FAQ and installation notes.
3. **Review and install** fetches your signed package from webfaze.com, checks its signature and checksum, and shows the permissions screen. The owner fills in your settings and agrees.
4. Afterwards, the same popup lets them turn your extension off and on, change its settings, see its activity, or remove it. Updates appear on the card and in the Updates filter.

Your banner, logo, short description and first screenshot do most of the selling: make them clear at small sizes.

## 18. Getting help and asking for new capabilities

| You want to | Go to |
| --- | --- |
| Ask how to build something, or share a problem while developing | webfaze.com/community, space **Developing extensions** |
| Propose a new capability (a new event, a new `wf` method, a wider permission) | webfaze.com/community, space **Ideas**: describe what your extension would do for site owners, what is missing, and the smallest addition that would make it possible |
| Support your users | Your listing's **Support** tab, and the **Extension support** space |
| Hear about changes to the API | webfaze.com/community, space **Announcements**, and the What's new notes of each Webfaze release |

Things extensions cannot do today, which the team knows about and you are welcome to ask for in Ideas:

- Calling services that need an `Authorization` header (the header is removed).
- Knowing which collection a record event belongs to without asking (`wf.content.get`).
- Adding page builder elements, public pages or Console screens.
- Reading draft content, or content of pages built with the page builder.

New capabilities arrive in Webfaze releases and are announced in the community; they are not granted to individual developers. When one ships, this guide is updated and the minimum Webfaze version needed is stated, so you can set **Works with Webfaze from** accordingly.

## 19. Questions developers ask

**Is this guide the complete reference? Is there an SDK?** Yes, this guide is complete for Webfaze 1.0.3.2. There is no SDK to install: the manifest, the events and `wf` are the whole interface. The types in section 12 give you autocompletion.

**Are there extra capabilities for approved developers, or on request?** No. Every developer, including Webfaze's own team, gets the same sandbox and the same `wf`. Webfaze's official extensions are part of Webfaze itself rather than sandboxed extensions, which is why they can add pages and builder elements.

**How do I test without publishing?** Run your own site with `WEBFAZE_DEV_EXTENSIONS=1` (section 3) and install your file from the Developer mode box.

**Why can a site owner not install my file directly?** On real sites extensions come only from the webfaze.com directory, so every one is reviewed and signed and can be pulled if a problem is found.

**Can my extension store data about visitors?** Only what the site owner would expect from your listing, and never tracking. Form submissions you receive belong to the site owner: send them only where your listing says.

**What happens to my extension's data when it is removed?** Its settings and its `wf.data` store are deleted. Content it created (drafts) stays.

**Does my extension run when the site's licence lapses?** No. Extensions pause while a site has no valid licence and start again when it is renewed.

**How do I rename my extension?** You can change the display name on your listing at any time, and the label in a new version. The `name` is permanent; a new name means a new listing.
