> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tappify.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Raise alerts, banners, and markers

> Send an event from your server and have Tappify put it in the owner's inbox, at the top of a page, or on a chart.

Your server sends one signed request; Tappify decides where it lands. A webhook declared as an
alert reaches the owner's inbox, one declared as a banner sits at the top of a page until
dismissed, and one declared as an event reaches your own widgets and, when a marker names it, the
time axis of a chart.

```bash theme={null}
tappify extension add webhook anomaly.detected --as alert
tappify extension add marker rollout --chart "*" --event rollout.finished --glyph "⚑"
```

A command that declares something your server answers also writes a `server.baseUrl` when the
manifest has none, using the placeholder `https://<your-id>.example.com`. That address answers
nothing, so `tappify extension publish` stops on its health check until you point `baseUrl` at a
server you have deployed.

## The manifest entry

```json theme={null}
{
  "scopes": [
    { "key": "ui:render" },
    {
      "key": "alerts:write",
      "justification": "Tells owners when installs fall off a cliff so they can act the same day."
    }
  ],
  "contributes": {
    "webhooks": [
      {
        "event": "anomaly.detected",
        "as": "alert",
        "payload": {
          "type": "object",
          "properties": {
            "why": { "type": "string" },
            "metric": { "type": "string" }
          },
          "required": ["why"]
        }
      },
      {
        "event": "anomaly.spiked",
        "as": "banner",
        "payload": {
          "type": "object",
          "properties": {
            "why": { "type": "string" },
            "metric": { "type": "string" }
          },
          "required": ["why"]
        }
      },
      {
        "event": "rollout.finished",
        "as": "event",
        "payload": {
          "type": "object",
          "properties": { "flag": { "type": "string" } },
          "required": ["flag"]
        }
      }
    ],
    "banners": [
      { "id": "spike", "page": "analytics", "event": "anomaly.spiked" }
    ],
    "markers": [
      {
        "id": "rollout",
        "label": "Flag rolled out",
        "chart": "*",
        "event": "rollout.finished",
        "glyph": "⚑"
      }
    ]
  },
  "server": { "baseUrl": "https://funnel-lab.dev" }
}
```

The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.

| `as`     | Where it lands                                                                                           |
| -------- | -------------------------------------------------------------------------------------------------------- |
| `alert`  | The owner's inbox, with your tile and the `why` sentence                                                 |
| `banner` | The top of the page a `banners` contribution names, until the owner dismisses it                         |
| `event`  | `tap.data.subscribe` in your own widgets, and the chart time axis when a `markers` contribution names it |

An event name is `domain.verb`, both parts `snake_case`, and one webhook carries one `as`. A banner
contribution names a webhook you declared with `as: "banner"`, and a marker names one you declared
with `as: "event"`; naming an event nothing declares under that `as` fails the manifest schema. An
event you want in the inbox and at the top of a page is two webhooks under two names, as
`anomaly.detected` and `anomaly.spiked` are above — Tappify resolves a delivery by its name alone,
so one name never carries two outcomes.

An `alert` or a `banner` payload must declare a required `why` string. That sentence is the whole
message the owner reads, so write it as one: `Installs fell 40% in an hour`, not
`ANOMALY_DETECTED`.

A marker's `chart` is one host chart or `"*"` for every one. Its `glyph` is at most two characters
and Tappify draws it, inside its own axis, with your tile on hover.

Declaring an `alert` or a `banner` needs `alerts:write`; a marker needs `ui:render` as well,
because it draws in the host.

## Sending one

`sendEvent` builds the envelope, signs it, and posts it.

```ts theme={null}
import { sendEvent } from "@tappify/extension-sdk/server";

await sendEvent(
  "anomaly.detected",
  { why: "Installs fell 40% in an hour", metric: "installs" },
  {
    installId,
    extensionId: "funnel-lab",
    secret: process.env.TAPPIFY_INBOUND_SECRET,
    dedupeKey: `anomaly-${projectId}-${hour}`,
    build: "1042",
  },
);
```

The event name and the payload type both come from your generated types, so a name you never
declared or a missing `why` is a compile error.

## The envelope, if you send it yourself

```http theme={null}
POST /api/v1/extensions/hooks/funnel-lab/{installId}
X-Tappify-Signature: <hex HMAC-SHA256 of the raw body>
Content-Type: application/json

{
  "event": "anomaly.detected",
  "source": "funnel-lab",
  "occurredAt": "2026-09-09T10:00:00Z",
  "build": "1042",
  "dedupeKey": "anomaly-prj_1-2026090910",
  "payload": { "why": "Installs fell 40% in an hour", "metric": "installs" }
}
```

The signature is the lowercase hex HMAC-SHA256 of the exact bytes you send, keyed on the inbound
secret from your extension's Server page. `source` is your extension id, and a `source` that is
not yours is refused. `build` is the owner's app version or build number when you know it, which
is what lets an alert land against a release.

Tappify answers 202 to every delivery it stores, whatever the delivery became.

## Resending

`dedupeKey` is yours to choose and Tappify stores it per install; leave it out and `sendEvent`
sends a fresh UUID, which makes every retry a new delivery. Send the same key twice and the second
delivery is recorded as a duplicate: no second alert, no second banner, no error. That makes a
retry loop on your side safe.

## Limits

Sixty deliveries a minute per install. Three alerts a day per project — a fourth is stored and
marked rate-limited so both of you can see it happened, but the owner is not told. An alert or a
banner on an install that never granted `alerts:write` is stored as rejected for the same reason.
Events are not counted against the alert limit.

## Replaying

An owner can re-run a delivery from the extension's page in their workspace, and you can re-run
one from your Runtime page. A replay re-runs the outcome against the stored payload; it does not
create a second delivery and does not call your server. Replaying an alert spends the project's
allowance for that day again, and is refused with `ALERT_LIMIT_REACHED` when the day is already
spent.
