# Events Hook

> Run plugin code when something happens in Broadcast, such as a signup, a bounce or a sent broadcast. Full example and common mistakes.

Source: https://sendbroadcast.net/docs/plugins-hook-events

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

Use this hook to react when something happens: a new subscriber, a bounce, a finished broadcast.

## How it works

1. Something happens in a channel.
2. Broadcast checks which plugins are on in that channel and list that event.
3. A background job calls your `onEvent` function.

The call happens after the fact. Your plugin cannot stop or change what happened.

## Full example: Slack alert

This plugin posts to Slack when someone subscribes.

`manifest.json`:

```json
{
  "id": "local.signup-alert",
  "name": "Signup alert",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "permissions": {
    "network": [{ "fromSetting": "webhook_url" }]
  },
  "hooks": {
    "events": ["subscriber.created"]
  },
  "settings": {
    "webhook_url": {
      "type": "url",
      "label": "Slack webhook URL",
      "secret": true,
      "required": true
    }
  }
}
```

`dist/main.js`:

```javascript
export default definePlugin({
  async onEvent(event, ctx) {
    // Broadcast can deliver one event more than once. Handle it only once.
    const doneKey = `done:${event.id}`;
    if (await ctx.store.get(doneKey)) return;

    const webhookUrl = await ctx.settings.secret("webhook_url");
    const response = await fetch(webhookUrl, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ text: "A new person subscribed." }),
    });

    // A throw makes Broadcast try this event again later.
    if (!response.ok) throw new Error(`Slack answered ${response.status}`);

    await ctx.store.set(doneKey, true);
    ctx.log.info("Posted one signup to Slack.");
  },
});
```

Pack and upload it as in [Your First Plugin](https://sendbroadcast.net/docs/plugins-first-plugin). Then paste your Slack webhook URL in the setting and turn the plugin on.

The manifest uses `fromSetting`: the plugin may reach the host of the URL the admin typed in the setting, and no other host.

## What you get

The first value is the event. What `data` holds depends on your permissions. With a `subscribers` permission, you get all fields:

```json
{
  "id": "evt_005c298f-0ac5-49b9-923b-f6eda80e22e8",
  "type": "subscriber.created",
  "occurred_at": "2026-11-01T18:51:15.829Z",
  "data": {
    "subscriber_id": 12,
    "email": "ada@example.com",
    "first_name": "Ada",
    "last_name": null,
    "subscribed_at": "2026-11-01T18:51:15.736Z",
    "unsubscribed_at": null,
    "confirmed_at": null,
    "is_active": true,
    "custom_data": {},
    "source": "opt_in_form",
    "change_source": "opt_in_form"
  }
}
```

`data` then holds the same fields as the webhook for that event. The [Webhooks](https://sendbroadcast.net/docs/webhooks) page lists every event and its data. A plugin can listen to all of them, except `test.webhook` and `message.attempt.exhausted`.

`data` holds `signup_ref` only when the person came through a form address with `?ref=`. See [Build a Referral Program](https://sendbroadcast.net/docs/plugins-build-a-referral-program).

### Without a subscribers permission

A plugin with no `subscribers` permission gets only a fixed list of fields that are not personal. Broadcast removes all other fields before the job is queued. For the same event, `data` is:

```json
{
  "subscriber_id": 12,
  "subscribed_at": "2026-11-01T18:51:15.736Z",
  "unsubscribed_at": null,
  "confirmed_at": null,
  "is_active": true
}
```

| Events | Fields you get |
|---|---|
| `subscriber.*` | `subscriber_id`, `is_active`, `subscribed_at`, `unsubscribed_at`, `confirmed_at`, `changed_at` |
| `subscribers.*` | Counts and times of the purge |
| `email.*` | IDs, `status`, `sent_at`, and the flags and times of delivered, opened, clicked, bounced, complaint and unsubscribed |
| `broadcast.*` | IDs, name, subject, status, tags, counts and times. Never the list of recipients |
| `sequence.*` | IDs, sequence and step names, status, `reason` and times |

You never get an email address, a name, `custom_data`, `signup_ref`, `source`, the `changes` of `subscriber.updated`, a bounce reason or an error message. This list only grows by a decision in Broadcast. A new field stays out until it is added.

To read the person, add `"subscribers": "read"` to the manifest and call `ctx.broadcast.subscribers.get(event.data.subscriber_id)`.

Warning

With a `subscribers` permission, event data holds personal data. Treat it with care. Do not log it, and do not send it to a host for a reason the admin does not expect.

## Common mistakes

**You handle an event twice.** Delivery is "at least once". A retry or a restart can bring the same event again, with the same `event.id`. If your work sends messages or adds up numbers, check `event.id` first, as the example does. If your work is safe to repeat, such as adding a tag, you do not need this.

**You expect an email address with no permission.** `event.data.email` is `undefined` unless the plugin has a `subscribers` permission.

**You catch every error.** If you hide an error, Broadcast does not try again. Let the error go up, as the example does with `throw`.

**You listen to too much.** `email.opened` can fire thousands of times for one broadcast. Each one is a call. Listen only to what you need.

**You expect `subscriber.created` to mean "confirmed".** On a double opt-in form, this event comes before the person confirms, and `confirmed_at` is `null`. The confirmation arrives later as `subscriber.updated`.

**You expect `subscriber.created` to run for an existing address.** It runs only when a new subscriber record is made.

**You expect an event right after you turn the plugin on.** Broadcast can take up to 30 seconds to notice.
