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

# Fetch data with hooks

> Read the owner's store data and call your own server through hooks that cache, dedupe and invalidate.

`useTapQuery` reads the owner's Tappify data. `useTapServer` calls one of your declared
procedures. Both return the same object, cache per install and input, dedupe across mounts,
and need no data library of your own.

```tsx theme={null}
import { useTapFilters, useTapQuery, useTapServer } from "@tappify/extension-sdk";

const filters = useTapFilters();

const downloads = useTapQuery({
  kind: "series",
  metric: "downloads",
  range: filters.range,
});

const summary = useTapServer("getSummary", { days: 7, platform: "all" });
```

Both give you `{ data, error, isLoading, refetch }`. A result stays fresh for 30 seconds
before the next mount asks again, and a failed call is not retried — `refetch()` is yours to
call.

## The queries you can run

| Query                                          | Returns                                                                                   | Needs                 |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------- |
| `{ kind: "project" }`                          | The project, its apps, releases and keywords                                              | `projects:read`       |
| `{ kind: "series", metric, range, platform? }` | A daily series. `metric` is `downloads`, `impressions`, `page_views` or `conversion_rate` | `analytics:read`      |
| `{ kind: "keywords" }`                         | The tracked keyword set with positions                                                    | `store.metadata:read` |
| `{ kind: "listing" }`                          | Store listing metadata per locale                                                         | `store.metadata:read` |
| `{ kind: "reviews", range }`                   | Review text, ratings and replies                                                          | `reviews:read`        |
| `{ kind: "crashes", range }`                   | Crash-free rate and issue counts                                                          | `crashes:read`        |
| `{ kind: "revenue", range }`                   | Revenue series and refund counts                                                          | `revenue:read`        |

Reads come from Tappify's own database, never live from the App Store or Google Play, so a
query is fast and cannot be rate-limited by a store.

A query whose scope the owner has not granted is refused before it reaches the data: nothing
comes back, and the hook's `error` names the scope that kind needs. Ask the bridge first when
a surface is optional:

```tsx theme={null}
import { TapEmptyState, useTap } from "@tappify/extension-sdk";

const tap = useTap();

if (!tap.auth.can("revenue:read")) {
  return <TapEmptyState title="Revenue is not shared with this extension" />;
}
```

## Options

```tsx theme={null}
const summary = useTapServer("getSummary", input, {
  enabled: filters.platform !== "all",
  invalidateOn: ["release.shipped", "settings.changed"],
});
```

| Option         | Means                                            |
| -------------- | ------------------------------------------------ |
| `enabled`      | Skip the call until the condition holds          |
| `invalidateOn` | Refetch when one of these Tappify events arrives |

`tap.invalidate("getSummary")` invalidates one procedure by name across every mount;
`tap.invalidate()` invalidates everything of yours.

## One request per input, not per mount

Two widgets that ask for the same series in the same range make one request. The cache key is
the install, the query and the filters behind it, so the same numbers render the same way in
the slot, in the expand panel and in the tab.

A procedure call carries `tap.filters` for you. A data query carries only the country
picker — `range` and `platform` are the ones you put in the query, which is why the sample
above passes `filters.range`. See
[Follow the page's filters](/extensions/build/follow-the-filters).
