> ## 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.

# Events

> Every event Tappify publishes, its payload, and the scope an install needs to receive it.

This is the reference for the Tappify event catalogue: twenty-one names, of which twelve are
published and carry a payload. Names are `domain.verb`, past tense, `snake_case`. Adding a field
to a payload is free; a renamed field is a new event name.

```tsx theme={null}
const unsubscribe = tap.data.subscribe("release.shipped", (payload) => {
  refetch(payload.version);
});
```

Events reach your UI through `tap.data.subscribe`, which the host serves by polling the install
every 15 seconds and fanning the page out to every mount. A subscription starts from the moment
your component subscribes, so it never receives a backlog. Your server declares the names it
wants under `server.events` and answers them at `POST /tappify/events`.

Delivery is filtered by what the owner granted: an install without `store.metadata:read` never
sees a `price.changed`. The types stay the full catalogue either way, so a handler compiles
whether or not the owner granted the scope it needs.

<Note>
  Both paths are live. An install that holds the scope sees the event through
  `tap.data.subscribe`; of those, the ones whose manifest lists the name under `server.events`
  also get it posted to `POST /tappify/events`.
</Note>

## The twelve published events

| Event                   | Needs                 | Sent today |
| ----------------------- | --------------------- | ---------- |
| `release.shipped`       | `projects:read`       | yes        |
| `metadata.changed`      | `store.metadata:read` | yes        |
| `keyword.set_changed`   | `store.metadata:read` | yes        |
| `screenshots.updated`   | `store.metadata:read` | yes        |
| `price.changed`         | `store.metadata:read` | yes        |
| `install.created`       | no scope              | yes        |
| `install.paused`        | no scope              | yes        |
| `install.resumed`       | no scope              | yes        |
| `install.revoked`       | no scope              | yes        |
| `install.token_rotated` | no scope              | yes        |
| `scopes.changed`        | no scope              | yes        |
| `settings.changed`      | no scope              | yes        |

The seven install events fire from the install, consent, rotation and retirement paths. The five
project events fire from the store paths that cause them: a release going live, a locale push, a
tracked-keyword change, a screenshot push and a price update.

### `release.shipped`

Needs `projects:read`. A release went live on a store.

```json theme={null}
{
  "projectId": "…", "platform": "ios", "appId": "…",
  "version": "4.2.0", "build": "1042", "releaseId": "…",
  "shippedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `projectId`, `platform`, `appId`, `version`, `shippedAt`. `build` can be null.
`platform` is `ios` or `android` on every project-scoped event.

### `metadata.changed`

Needs `store.metadata:read`. Store listing metadata changed for a locale.

```json theme={null}
{
  "projectId": "…", "platform": "ios", "appId": "…",
  "locale": "en-US", "fields": ["subtitle", "description"],
  "changedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `projectId`, `platform`, `appId`, `locale`, `fields`, `changedAt`.

### `keyword.set_changed`

Needs `store.metadata:read`. The tracked keyword set changed in a country.

```json theme={null}
{
  "projectId": "…", "platform": "ios", "appId": "…", "country": "US",
  "added": ["photo editor"], "removed": [], "total": 42,
  "changedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `projectId`, `platform`, `appId`, `country`, `changedAt`. `added`, `removed` and
`total` describe the change and can be absent.

### `screenshots.updated`

Needs `store.metadata:read`. Screenshots changed for a locale.

```json theme={null}
{
  "projectId": "…", "platform": "ios", "appId": "…",
  "locale": "en-US", "deviceClass": "iphone-6.7", "count": 6,
  "updatedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `projectId`, `platform`, `appId`, `locale`, `updatedAt`. `deviceClass` and
`count` can be absent.

### `price.changed`

Needs `store.metadata:read`. A price changed in a territory.

```json theme={null}
{
  "projectId": "…", "platform": "ios", "appId": "…", "territory": "US",
  "productId": "pro_yearly", "from": 39.99, "to": 29.99, "currency": "USD",
  "changedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `projectId`, `platform`, `appId`, `territory`, `to`, `currency`, `changedAt`.
`productId` and `from` can be null, so a first price carries `to` with no `from`.

### `install.created`

No scope. Your extension was installed.

```json theme={null}
{
  "installId": "…", "extensionId": "starter", "projectId": "…",
  "organizationId": "…", "installScope": "project", "source": "directory",
  "grantedScopes": ["ui:render", "analytics:read"],
  "createdAt": "2026-09-08T10:14:00Z"
}
```

Always present: `installId`, `extensionId`, `organizationId`, `installScope`, `source`,
`grantedScopes`, `createdAt`. `installScope` is `project` or `organization`, and `projectId` is
null on an organization install. `source` is one of `directory`, `link`, `recipe`, `assistant`,
`connect`, `dev`.

### `install.paused` and `install.resumed`

No scope. The owner paused or resumed the install.

```json theme={null}
{
  "installId": "…", "extensionId": "starter", "projectId": "…",
  "organizationId": "…", "pausedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `installId`, `extensionId`, `organizationId`, and `pausedAt` or `resumedAt`.
Neither event carries `installScope`, `source` or `grantedScopes` — those are on
`install.created` alone.

### `install.revoked`

No scope. The install ended.

```json theme={null}
{
  "installId": "…", "extensionId": "starter", "projectId": "…",
  "organizationId": "…", "reason": "uninstalled",
  "revokedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `installId`, `extensionId`, `organizationId`, `revokedAt`. `reason` is
`uninstalled` when the owner uninstalls and `retired` when you retire the extension. The payload
schema also allows `suspended`; nothing sends it today.

### `install.token_rotated`

No scope. The install's token generation and inbound secret were rotated; re-read the secret from
the portal.

```json theme={null}
{
  "installId": "…", "extensionId": "starter", "projectId": "…",
  "organizationId": "…", "generation": 4,
  "rotatedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `installId`, `extensionId`, `organizationId`, `generation`, `rotatedAt`. The new
inbound signing secret is never in the payload — it is shown once, to the owner, in the portal.

### `scopes.changed`

No scope. The owner granted or removed scopes.

```json theme={null}
{
  "installId": "…", "extensionId": "starter", "projectId": "…",
  "organizationId": "…",
  "grantedScopes": ["ui:render", "analytics:read", "revenue:read"],
  "addedScopes": ["revenue:read"], "removedScopes": [],
  "changedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `installId`, `extensionId`, `organizationId`, `grantedScopes`, `changedAt`.
`grantedScopes` is the whole set after the change, so a handler can replace rather than patch.

### `settings.changed`

No scope. The owner saved your settings panel.

```json theme={null}
{
  "installId": "…", "extensionId": "starter", "projectId": "…",
  "organizationId": "…", "keys": ["refreshMinutes"],
  "changedAt": "2026-09-08T10:14:00Z"
}
```

Always present: `installId`, `extensionId`, `organizationId`, `keys`, `changedAt`. `keys` names
the fields that changed and carries none of their values — an owner's settings document holds
whatever they typed into your form, so the event says what moved and not what it now says. Read
the values back with `useTapStorage("settings")`.

## Nine reserved names

These names are in the catalogue and carry no payload. A manifest that lists one under
`server.events` validates, the registry marks it unpublished, and nothing is ever delivered
until the feature that emits it ships.

| Name                     | Scope it will need    |
| ------------------------ | --------------------- |
| `featuring.started`      | `store.metadata:read` |
| `score.changed`          | `projects:read`       |
| `incident.opened`        | `crashes:read`        |
| `incident.resolved`      | `crashes:read`        |
| `playbook.step_approved` | `projects:read`       |
| `review.thread_opened`   | `reviews:read`        |
| `review.thread_resolved` | `reviews:read`        |
| `approval.requested`     | no scope              |
| `digest.sent`            | no scope              |

## Your own events

Events you declare under `contributes.webhooks` travel the other way: your server posts them to
Tappify, and `as` decides whether one becomes an alert, a banner, or an event your UI can
subscribe to. Their names follow the same `domain.verb` shape. 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 fails the manifest schema.

<Card title="Raise alerts, banners, and markers" icon="bell" href="/extensions/build/alerts-banners-markers">
  Declaring one, signing the envelope, and what each `as` does to the delivery.
</Card>

## Receiving Tappify's events on your server

Declare which of the events above your server should receive:

```json theme={null}
{
  "server": {
    "baseUrl": "https://funnel-lab.dev",
    "events": ["release.shipped", "install.revoked"]
  }
}
```

```bash theme={null}
tappify extension events add release.shipped
tappify extension events remove release.shipped
```

Tappify posts each one to `POST /tappify/events`:

```json theme={null}
{
  "install": { "id": "…", "extensionId": "funnel-lab", "projectId": "…", "organizationId": "…" },
  "context": {
    "projectId": "…", "platform": "all", "from": "…", "to": "…",
    "filters": { "range": { "from": "…", "to": "…" }, "platform": "all", "country": "all" }
  },
  "event": { "name": "release.shipped", "payload": { "version": "4.2.0", "…": "…" } },
  "credentials": { "…": "…" },
  "documents": { "…": "…" }
}
```

`request.event` is the payload alone, and the route answers 204:

```ts theme={null}
export default createTappifyHandler({
  extensionId: "funnel-lab",
  events: {
    "release.shipped": async (request) => {
      await recordRelease(request.event.version);
    },
  },
});
```

A subscription needs the same scope the event needs in the browser: `release.shipped` needs
`projects:read`, the four store events need `store.metadata:read`, and the install and settings
events need none. An install that does not hold the scope is skipped, and a name outside the
published catalogue fails the manifest schema and the publish with it.

Answer 2xx. A 5xx or an unreachable server is retried three times, waiting 30 seconds, then 2
minutes, then 10 minutes, and then dropped, for that install alone; a 4xx is taken as your answer
and recorded on the install's runtime timeline instead. Every attempt carries its own
`X-Tappify-Event-Id`, so deduplicate on what the payload itself names — `releaseId` on
`release.shipped`, `changedAt` on the store events — rather than on the header.

<Card title="Subscribe to Tappify events" icon="signal-stream" href="/extensions/build/subscribe-to-events">
  Handling these in a component and on your server.
</Card>
