# Plugin Reference

> Every manifest field, ctx function, permission, setting type, event field and limit of the Broadcast plugin system.

Source: https://sendbroadcast.net/docs/plugins-reference

> Coming in Broadcast 3.0. Until then, this page applies only to the Upcoming (edge) build.

This page lists everything in one place. To learn the ideas, start with [Plugin Core Concepts](https://sendbroadcast.net/docs/plugins-concepts).

## Package files

A `.broadcast-plugin` file is a zip file.

| Path | Required | Holds |
|---|---|---|
| `manifest.json` | Yes | The manifest |
| `dist/main.js` | Yes | The code that runs |
| `ui/...` | No | HTML, CSS and JavaScript of pages |
| `assets/...` | No | Images and fonts |
| `src/...` | No | Your source files, for reference |
| `vendor/...` | No | Copied dependencies, for reference |
| `README.md`, `LICENSE` | No | Text for the reader |
| `package.json`, `deno.json`, `deno.lock` | No | Build files, for reference |

Rules for `dist/main.js`:

- One ES module with `export default definePlugin({ ... })`. `definePlugin` is a global.
- No `import`, `export ... from`, `import()` or `import.meta`. Put all code in this file.
- No `eval`, `Function()` or `new Function()`.
- No line longer than 2,000 characters, so no minified code.
- JavaScript only. Compile TypeScript first.

## Manifest fields

| Field | Required | Rule |
|---|---|---|
| `id` | Yes | `publisher.name`. Lower case letters, digits and `-`. A plugin without a directory signature must use `local.` |
| `name` | Yes | The name people see |
| `version` | Yes | Semantic version, such as `1.2.0` or `1.2.0-beta.1` |
| `requires.broadcast` | Yes | A version range, such as `>=3.0.0 <4.0.0` |
| `requires.pluginApi` | Yes | `"1"` |
| `permissions` | No | See [Permissions](#permissions) |
| `hooks` | No | See [Hooks](#hooks) |
| `settings` | No | See [Settings](#settings) |
| `events` | No | Declared events with `value`, `properties` and `webhook`. See [Plugin Events and Segment Rules](https://sendbroadcast.net/docs/plugins-segment-rules) |
| `segmentRules` | No | See [Plugin Events and Segment Rules](https://sendbroadcast.net/docs/plugins-segment-rules) |
| `ui.pages` | No | A list of `{ "slot", "title", "path" }`. See [Plugin Pages](https://sendbroadcast.net/docs/plugins-pages) and [Signal](https://sendbroadcast.net/docs/signal) |

## Icon and listing

Both are optional. They go through review with your code, and the signature covers them.

```json
{
  "icon": "assets/icon.png",
  "listing": {
    "features": ["One short line per feature"],
    "support": "help@example.com",
    "privacy": "https://example.com/privacy",
    "changelog": "What changed in this version."
  }
}
```

| Field | Rule |
|---|---|
| `icon` | A PNG in `assets/`, exactly 512 x 512 pixels, 256 KB at most |
| `listing.features` | Up to 3 lines, each up to 80 characters |
| `listing.support` | An email address or an `https` address |
| `listing.privacy` | An `https` address |
| `listing.changelog` | Text, up to 2000 characters, for this version |

SVG is not accepted. Leave the corners square, because Broadcast rounds them. Use no words in the icon, and do not copy another brand.

A plugin with no icon shows its first letter on a colour tile.

## Hooks

| Key | Value |
|---|---|
| `events` | A list of event names |
| `schedules` | A list of `{ "name", "cron" }` |
| `sequenceSteps` | A list of names, or `{ "name", "label", "settings" }` |
| `mergeTags` | A list of names, or `{ "name", "output": "html" }` |
| `liquidFilters` | A list of names, or `{ "name", "output": "html" }` |
| `aiTools` | A list of `{ "name", "description", "input_schema", "readOnly" }` |
| `apiEndpoints` | A list of `{ "name", "method", "permission" }`. `method` is `GET` or `POST` |
| `publicActions` | A list of `{ "name" }` |
| `beforeSend` | `true` |
| `onSubscriberDeleted` | `true` |

A name has 1 to 40 characters: lower case letters, digits and `_`, starting with a letter.

In a map hook, an entry is a function, or an object with a `run` function. `mergeTags` and `liquidFilters` can also be one function for all names.

## Settings

| Field | Meaning |
|---|---|
| `type` | Required. See below |
| `label` | The text next to the field |
| `secret` | `true` stores it as a secret. Read it with `ctx.settings.secret` |
| `required` | `true` means it must have a value before the plugin can be turned on |
| `options` | For `select`: a list of strings |

| Type | Form field | Your code gets |
|---|---|---|
| `string` | One line | A string |
| `text` | Several lines | A string |
| `url` | One line | A string. Broadcast does not check the form |
| `integer` | One line | A string, such as `"5"`. Broadcast does not check the form |
| `boolean` | Yes or No | `"true"` or `"false"`, as strings |
| `select` | A list | A string |

## ctx

### Your own things

| Call | Answer |
|---|---|
| `ctx.channel` | `{ id, name }` |
| `ctx.settings.get(name)` | The value, or `undefined`. No `await` |
| `await ctx.settings.secret(name)` | The value, or `null` |
| `await ctx.store.get(key)` | The value, or `null` |
| `await ctx.store.set(key, value)` | `true` |
| `await ctx.store.delete(key)` | `true` if a row was deleted |
| `await ctx.store.increment(key, by = 1)` | The new number. Atomic. `by` is a whole number and can be negative |
| `await ctx.store.list(prefix)` | A list of `{ key, value }`, at most the first 100 rows |
| `ctx.store.forSubscriber(id)` | A store with the same five functions, for one person |
| `ctx.log.info(...)`, `.warn(...)`, `.error(...)` | Nothing. No `await`. Never throws |

### Subscribers

| Call | Permission | Answer |
|---|---|---|
| `subscribers.get(id)` | `read` | A subscriber. Throws `not_found` |
| `subscribers.find(email)` | `read` | A subscriber, or `null` |
| `subscribers.list({ limit, cursor, updated_since })` | `read` | `{ items, next_cursor }` |
| `subscribers.update({ id, first_name, last_name, custom_data })` | `write` | A subscriber |
| `subscribers.bulkUpsert({ items, idempotency_key })` | `write` | One result for each item |

All are under `ctx.broadcast`.

A subscriber has these fields: `id`, `email`, `first_name`, `last_name`, `is_active`, `subscribed_at`, `unsubscribed_at`, `confirmed_at`, `tags`, `custom_data`, `source`, `signup_ref`, `created_at` and `updated_at`.

Good to know:

- Without a `limit`, `subscribers.list` gives up to 100 people.
- A `limit` over 100 or a `cursor` needs the `export` permission. Then one page can have 1,000 people.
- `update` merges `custom_data` with the old value. It cannot change the email or consent.
- A `custom_data` key that holds a topic is refused with `forbidden`, in `update` and `bulkUpsert`. See [Topics](#topics).
- `bulkUpsert` items have `email`, `first_name`, `last_name`, `custom_data` and `tags`. It updates people who exist. To create people, it needs `create`, and a suppressed address is stored as inactive.

### Tags

| Call | Permission | Answer |
|---|---|---|
| `tags.list()` | `read` | Tag names, up to 1,000 |
| `tags.add({ subscriber_id, tags })` | `write` | The person's tags |
| `tags.remove({ subscriber_id, tags })` | `write` | The person's tags |
| `tags.bulkAdd({ subscriber_ids, tags })` | `write` | One result for each ID |
| `tags.bulkRemove({ subscriber_ids, tags })` | `write` | One result for each ID |

### Topics

A topic is a subscriber's consent, and it is stored in a tag or a `custom_data` key. No plugin can change it. If a tag matches a topic tag of the channel, without regard to case, Broadcast refuses the write with the code `forbidden`:

- `tags.add` and `tags.remove` throw, and change nothing. This is also true if only one tag in the list is a topic.
- `tags.bulkAdd`, `tags.bulkRemove` and `subscribers.bulkUpsert` refuse that item, and go on with the others. The item has `"ok": false` and the code.

Catch the error if the rest of your work must go on:

```javascript
try {
  await ctx.broadcast.tags.add({ subscriber_id: id, tags: [tag] });
} catch (error) {
  if (error.code !== "forbidden") throw error;
  ctx.log.warn(`Tag ${tag} was refused.`);
}
```

Tag changes are safe to repeat.

### Broadcasts, sequences and segments

| Call | Permission | Answer |
|---|---|---|
| `broadcasts.get(id)`, `sequences.get(id)`, `segments.get(id)` | `read` on that area | A summary. Throws `not_found` |
| `broadcasts.list({ limit, offset })`, also `sequences` and `segments` | `read` on that area | `{ items }`, up to 100 |

Summaries have no email bodies and no recipient lists.

| Area | Fields |
|---|---|
| Broadcast | `id`, `name`, `subject`, `status`, `scheduled_send_at`, `sent_at`, `total_recipients`, `sent_count`, `tags` |
| Sequence | `id`, `name`, `active`, `init_tag`, `created_at` |
| Segment | `id`, `name`, `description`, `subscribers_count`, `created_at` |

### Events

| Call | Answer |
|---|---|
| `events.record({ name, subscriber_id or email, value, properties, occurred_at, idempotency_key })` | `{ ok }`, or `{ ok: false, error }` |
| `events.bulkRecord({ items, idempotency_key })` | One result for each item |

### fetch

`fetch(url, options)` works as usual, for declared hosts only. The call goes through Broadcast's network gate, which refuses private addresses and does not follow redirects.

## Permissions

### Basic

Every plugin has these. Do not declare them: its own settings, storage, logs and events, and the channel name.

With no `subscribers` permission, a plugin's `event.data` holds only fields that are not personal. See [Events Hook](https://sendbroadcast.net/docs/plugins-hook-events).

### Standard

| Manifest | Allows | The admin sees |
|---|---|---|
| `"subscribers": "read"` | `get`, `find`, `list` (first 100) | Read subscribers |
| `"subscribers": "write"` | `read`, plus `update` and `bulkUpsert` of existing people | Change subscribers |
| `"tags": "read"` | `tags.list` | Read tags |
| `"tags": "write"` | `read`, plus tag changes | Change tags |
| `"broadcasts": "read"` | Read broadcasts | Read broadcasts |
| `"sequences": "read"` | Read sequences | Read sequences |
| `"segments": "read"` | Read segments | Read segments |
| `"network": ["api.example.com"]` | Exact hosts. Port 443, unless you add one such as `api.example.com:8443` | Internet access |

### Sensitive

| Manifest | Allows | The admin sees |
|---|---|---|
| `"subscribers": ["read", "export"]` | Reading past the first 100 | Copy your whole list |
| `"subscribers": ["write", "create"]` | `bulkUpsert` can create people, up to 5,000 an hour | Add new subscribers |
| `"network": ["*.example.com"]` | One level of subdomains | Internet access to many sites |
| `"network": [{ "fromSetting": "x" }]` | The host of the URL in setting `x`, which must have type `url` | Internet access to an address you enter |
| `"output": "html"` | Merge tags and filters can return HTML, which is cleaned | HTML in emails |
| `hooks.beforeSend` | Added by the hook | Change emails before they are sent |
| `hooks.apiEndpoints` | Added by the hook | Its own API |
| `hooks.publicActions` | Added by the hook | Links anyone can click in emails |
| `"webhook": true` on a declared event | An admin can send the event to a webhook endpoint | Its events in webhooks |

### Elevated

An admin must turn these on for each channel.

| Manifest | Allows | The admin sees |
|---|---|---|
| `"elevated": ["network.private"]` | One host on a private network, which the admin enters | Access to one private address |
| `"elevated": ["email:sender"]` | Reserved. No hook uses it yet | Change the sender |

### Never available

`sql`, `files`, `env`, `process`, `subscribers:resubscribe`, `suppressions:write`, `email:recipients` and `email:unsubscribe`. If you declare one, the manifest is refused.

### Network hosts

A host must be a public DNS name with at least two parts. These are refused: IP addresses, `localhost`, and names that end in `.local`, `.localhost`, `.internal`, `.lan`, `.home`, `.corp`, `.intranet`, `.test`, `.invalid`, `.example` or `.arpa`. Write the host only, with no `https://`.

## Limits

### Time

| Limit | Value |
|---|---|
| A normal hook call | 10 seconds |
| Merge tags, Liquid filters, before send, public actions | 5 seconds |
| Failures in a row before a plugin is turned off | 20 |
| Retries: events, subscriber deleted | Up to 5 attempts |
| Retries: schedules | Up to 3 attempts |
| Retries: sequence steps | Up to 5 attempts |
| Shortest gap between schedule runs | 5 minutes |
| A process with no calls stops after | 5 minutes |

### Size

| Limit | Value |
|---|---|
| Send-time batch | 100 people |
| Merge tag or filter value | 10,000 characters |
| Before send: subject | 998 characters |
| Before send: each HTML addition | 10,000 characters |
| Public action: value | 200 characters |
| Public action: title | 200 characters |
| Public action: message | 1,000 characters |
| API endpoint body | 1 MB |
| Bulk call | 1,000 items |
| Idempotency answers are kept for | 1 day |

### Rates

| Limit | Value |
|---|---|
| Public action clicks for one link | 5 a minute |
| Public action clicks from one IP address | 60 a minute |
| Calls at once for one plugin | 4 |
| Calls that can wait in line | 50 |
| New subscribers from one plugin | 5,000 an hour |

### Storage

| Limit | Value |
|---|---|
| Store value | 256 KB |
| Store total, for each plugin and channel | 10 MB |
| Store key | 200 characters |
| `store.list` | 100 rows |
| Plugin events, for each plugin and channel | 2,000,000 |
| Linked events are kept for | 2 years |
| Events with no subscriber are kept for | 90 days |
| Segment rules | 10 |
| `where` filters in a rule | 5 |

### Memory

| Limit | Value |
|---|---|
| Heap of one plugin process | 64 MB |
| All plugins, by default | 256 MB |

### Logs

| Limit | Value |
|---|---|
| Line length | 2,000 characters |
| Lines saved from one call | 100 |
| Lines are kept for | 30 days |

### Packages

| Limit | Value |
|---|---|
| Package | 5 MB |
| Package, unpacked | 20 MB |
| One file | 5 MB |
| Files | 500 |
| `manifest.json` | 256 KB |

### Draft tests

| Limit | Value |
|---|---|
| Generated subscribers | 25 |
| Fixtures | 100 |
| One fixture body | 1 MB |
| Plugin developer token | Expires within 30 days |
