# LiteBits Ads — publisher SDK (v1)

Rewarded ads for Telegram Mini Apps — games, wallets, tools, anything that
runs inside Telegram and wants to reward its users. One script tag, one call,
one server callback. The SDK draws a full-screen ad, runs the countdown, confirms the
view with our server and hands you the result. **Your server pays your user when
our callback arrives — never from the browser.**

When we have no campaign for a user, the SDK can run **your own accounts** on
other networks (Gigapub, Adsgram, TowerAds, Telega, RichAds) in the order you set per
placement — one SDK, one fill report, and those views are paid by that network,
not by us (§5).

**The SDK runs inside a Telegram Mini App.** It identifies the viewer from the
signed `Telegram.WebApp.initData` your Mini App already has; there is no user id
to pass and nothing for you to look up. Plain web pages are not supported in v1
(outside Telegram only `sandbox` works).

## 1. Install

```html
<script src="https://ads.litebits.io/sdk/v1.js"></script>
```

Local development: `http://localhost:4200/sdk/v1.js`. The SDK talks to the origin
it was loaded from, so no `baseUrl` is needed unless you self-host the file.

Vanilla JS, ~40 KB minified (~15 KB gzipped, overlay strings in 14 languages included), no dependencies, ES2018. Exposes `window.LiteBitsAds`.
Third-party network scripts (§5) are fetched only when a fallback step is actually reached, never on page load.

### Prerequisite: tell us which bot your Mini App belongs to (once)

Open the dashboard ([@litebits_ads_bot](https://t.me/litebits_ads_bot) → Open
dashboard) → Settings → "Telegram bot" and enter your bot's **id or @username**.
No token: since Bot API 8.0 Telegram signs every Mini App's `initData` with its
own Ed25519 key over the data and your bot id (`signature` field), and we check
that signature with Telegram's public key, so nobody can fake your users. Only
know the @username? Forward any message from your bot to @litebits_ads_bot and it
connects the bot for you.

Until a bot is connected every real ad request answers `409 publisher_bot_missing`,
which the SDK surfaces as `NOT_CONFIGURED` (not as a no-fill). Sandbox requests
work without it. Accounts that saved a bot token earlier keep working unchanged
(we then check Telegram's HMAC `hash` with it).

## 2. Show an ad (8 lines)

```js
const ads = new LiteBitsAds({ apiKey: 'lba_…', placementId: '…' });
ads.on('rewarded', (r) => console.log('server says', r));   // informational only
try {
  const r = await ads.show();   // resolves when the user taps Done (or a fallback network showed)
  if (r.fallback) return;       // shown by YOUR account on r.network — that network pays this user, we do not (§5)
  if (r.verified) showToast('Thanks! Your reward is on its way');   // credit happens via callback (§6)
} catch (err) {
  if (err.code === 'NO_FILL') showSomethingElse();  // neither we nor any of your fallback networks had an ad
}
```

`show()` reads `window.Telegram.WebApp.initData` fresh on every call and sends it
to our server, which verifies Telegram's signature for your bot and takes the
Telegram user id from it. Call it wherever the user opts in — e.g. on a claim, a
level-up, a daily bonus — there is nothing else to wire up.

### Options

| option        | required | default                       | notes |
|---------------|----------|-------------------------------|-------|
| `apiKey`      | yes      |                               | publisher key from the dashboard (Integration tab). It is publishable: it lives in your Mini App code and can only request, show and confirm ads. Your stats, earnings, payout address and callback settings open only in the dashboard, from your Telegram account. |
| `placementId` | yes      |                               | placement UUID |
| `baseUrl`     | no       | origin of the `<script>` tag  | e.g. `http://localhost:4200` |
| `lang`        | no       | Telegram user's `language_code`, else `navigator.language` | two-letter code, used for targeting and (SDK 1.6.0) for the overlay's own text (§4 "Language"). Server v1.21: for targeting, the `language_code` inside the signed `initData` wins when present; this value is the fallback |
| `sandbox`     | no       | `false`                       | `true` → fixed demo creative, nothing billed, nothing earned; works outside Telegram too. Sandbox never runs fallbacks. |
| `fallbacks`   | no       | `true`                        | `false` → never run the placement's fallback networks (§5); a LiteBits no-fill rejects `NO_FILL` straight away |

There is no `userId` option. The user is whoever Telegram says it is.

### Methods

- `ads.on(event, fn)` → returns an unsubscribe function.
- `ads.show()` → `Promise<{ verified, earning, earning_msat?, campaign_id, impression_id, tapped, visited, reason? }>` for a LiteBits ad (SDK 1.6.0: `tapped` is whether the user opened the ad's link during this view — badge taps do not count; SDK 1.7.0: `visited` is whether they also stayed 10 s on the advertiser's page through our visit page, §4b) (v1.6: `earning` is the whole sats booked by this view and may be 0 while `earning_msat` carries the exact value — see CONTRACT-channels.md "Millisat settlement"; SDK 1.4.0: `impression_id` identifies this view on our side — store it next to your own reward if you want to reconcile against your callbacks or stats), or `Promise<{ verified: false, fallback: true, network }>` when one of your fallback networks showed instead; see §4 and §5.
- `ads.destroy()` → removes an open ad (rejecting the pending promise with `ABANDONED`), drops all listeners. The instance cannot be reused.

Only one ad can be on screen at a time, across all instances on the page; a
second `show()` rejects with `BUSY`.

### What the SDK sends

`POST /v1/ad/request` with `{ placement_id, init_data, lang, platform: 'telegram' }`
— `init_data` is the raw `Telegram.WebApp.initData` string, untouched. In sandbox
mode outside Telegram it sends `sandbox_user_id: 'demo'` instead of `init_data`.

## 3. Events

| event      | payload                                   | when |
|------------|-------------------------------------------|------|
| `loaded`   | `ad` (`id, creative_type, title, url, image_url, framable, card, min_seconds, …`) | the server returned a creative (`framable` / `card` decide how a `url` creative renders, §4a) |
| `opened`   | `ad`                                      | the overlay is on screen, countdown running |
| `tap`      | `{ url, campaign_id, impression_id }`      | SDK 1.6.0: the user opened the ad's link (CTA, card, image or "Open in browser"), once per tap; the badge does not fire it. Informational — never pay on it. |
| `visit`    | `{ url, campaign_id, impression_id }`      | SDK 1.7.0: the user stayed 10 s on the advertiser's page through our visit page and our server verified it (§4b). Once per view, usually when the user comes back to your app. Informational — never pay on it. |
| `rewarded` | `{ verified, earning, campaign_id, impression_id, tapped, visited, reason? }` | `/v1/ad/complete` answered. `verified:false` carries a `reason` (`too_fast`, `frequency`, `user_cap`, `expired`, `mismatch`, `campaign_cap` (v1.21), …). Informational — see §6. Never fired for a fallback network. |
| `nofill`   | `{ placementId }`                         | LiteBits had no eligible campaign (server answered 204). Nothing of ours was drawn. If the placement has fallback networks, the chain starts right after this event. |
| `fallback` | `{ network, outcome, error? }`            | one fallback step finished: `outcome` is `shown` \| `no-fill` \| `timeout` \| `error` (§5). Fired once per step, in chain order, until one shows. |
| `error`    | `Error` with `.code`                      | any failure; also fired before a rejection |
| `closed`   | `{ outcome: 'shown' \| 'error' \| 'abandoned' }` | the overlay left the screen |

Listener exceptions are logged and swallowed — they can never break an ad in progress.

## 4. Errors (`err.code`)

| code             | meaning | what to do |
|------------------|---------|------------|
| `NO_FILL`        | LiteBits had no ad and none of your fallback networks showed one either (or `fallbacks: false`) | show your own placeholder; the `fallback` events tell you what each network said |
| `TIMEOUT`        | the ad request took longer than 20 s | treat like no fill |
| `ABANDONED`      | `destroy()` was called while an ad was open | nothing |
| `NETWORK`        | request failed, or the view could not be confirmed after retries | treat like no fill; do **not** pay |
| `CONFIG`         | bad/missing option, invalid key, unknown placement, or **not running inside a Telegram Mini App** (message: "LiteBits Ads runs inside a Telegram Mini App") | fix the integration; check the console |
| `NOT_CONFIGURED` | your publisher account has no bot connected yet (server `409 publisher_bot_missing`) | enter your bot id or @username in the dashboard (§1); until then treat like no fill |
| `AUTH`           | our server rejected the Telegram `initData` (server `401 init_data_invalid`): Telegram's signature is not for the bot in your dashboard, `initData` has no `signature` (a Telegram app older than Bot API 8.0 on a bot-id account), or `auth_date` is older than 48 h | check that the dashboard bot is the one this Mini App is opened from; otherwise treat like no fill |
| `BUSY`           | an ad is already open | ignore the second call |

`LiteBitsAds.ERROR_CODES` lists all of them. Every rejection is also emitted as
an `error` event first.

`show()` **resolves** even when `verified` is `false` — the user did watch,
but the view will not be paid (frequency cap hit, daily cap, too fast, …). The
value tells you why; whether you show the user anything is your call. A resolved
value with `fallback: true` is a different thing entirely: one of **your** other
networks showed the ad (§5).

### What the user sees

Full-screen dark overlay. The header carries a **LiteBits Ads · Sponsored**
badge (the LiteBits Ads megaphone mark + "LiteBits Ads" + a muted "Sponsored") so users
see where the ad comes from; it links to `https://ads.litebits.io/about?p=<placement_id>` (SDK 1.10.0), a
public page where your viewer can advertise, claim free LiteBits Coins, or monetize their own Mini App
and opens like the CTA does (`Telegram.WebApp.openLink`, else `window.open`
noopener) — tapping it never closes the ad or stops the countdown. The countdown
(`min_seconds`, set per placement, default 8) sits on the right. The call to action
sits at the bottom of the screen, above the progress bar, where the thumb is (SDK
1.6.0), and stays there after "Done" appears — except on the Telegram card, where it
sits inside the card under the description (SDK 1.8.0); a few seconds into the countdown an
untapped CTA pulses once (never under `prefers-reduced-motion`). The badge is
also on the sandbox creative. The creative is rendered inside the overlay in one of
four ways (table below). Tapping the creative opens the advertiser
via `Telegram.WebApp.openLink` / `openTelegramLink` **without** closing the ad.
The countdown pauses while the tab is hidden — except after the user tapped through
to the advertiser (SDK 1.6.0): from then on the time spent on the advertiser's page
counts, so opening the ad never makes the reward wait longer. There is no close control until the
view has been confirmed by our server; then a full-width "Done" button appears.
The ad never disappears on its own — the user closes it when they are done.
Taps inside the overlay stay inside it (SDK 1.5.0): they do not bubble up to
click listeners on your `document`, so another ad script on the page cannot
turn a tap on our ad into one of its own.
These rules are deliberate: on our own faucet they cut ad abandonment by ~29%.

**Language (SDK 1.6.0).** The overlay's own text — the default CTA ("Open", "Open in
Telegram", "Open in browser"), "Reward unlocks in N s", "Done"/"Close" and the status
lines — follows `lang`: `en`, `ru`, `uk`, `es`, `pt`, `de`, `fr`, `it`, `tr`, `id`,
`vi`, `fa`, `ar`, `zh`; anything else is English. `fa` and `ar` are laid out right to
left. A campaign's own `cta_text` is shown as is (a bare "Open" counts as no choice
and is translated).

**Taps (SDK 1.6.0).** The first time the user opens the ad's link during a view the
SDK tells our server (`POST /v1/ad/tap`, fire-and-forget); your dashboard shows it as
the tap rate (taps ÷ fills). The `tap` event and `tapped` in the result are there if
you want the same number in your own analytics. Since SDK 1.7.1 the report goes out as
a `sendBeacon` and your `tap` handlers run before the link opens, so a tap is not lost
when Telegram switches to the advertiser; send anything of your own from `tap` with
`fetch(..., { keepalive: true })` or `navigator.sendBeacon`.

### 4b. The visit page (SDK 1.7.0)

For some page ads a tap does not open the advertiser directly: it opens our visit page,
`https://ads.litebits.io/v/…`, in Telegram's browser. The user sees the advertiser's page
full screen with a small ring in the top corner that counts 10 seconds **while the page is
on screen** ("Stay on the page"; it pauses as "Paused" when they switch away). Then it
turns green ("Visit verified") and offers **Continue to <site>** (the advertiser, opened
normally, so sign-ups and referral links work) and **Back to <your app>**.

- It applies only to page ads we can show in a frame (never t.me links, never images) and
  only while LiteBits has it switched on for the network; the ad then carries `visit_url`
  and draws as the web card (the page itself is shown on the visit page, not in your app).
  Every other ad opens exactly as before.
- Nothing changes for your reward: the countdown in your app keeps running while the user
  is on the page (as after any tap, SDK 1.6.0), `complete`, earnings and the callback are
  the same. A visit is measured, not billed or paid.
- When the user comes back to your app the SDK asks our server whether the visit finished
  and fires `visit`; `show()` resolves with `visited: true | false` (one last check at Done,
  at most 1.5 s, so Done is never held). Your dashboard shows it as "% visited" under the
  tap rate.
- You add nothing: no new option, no CSP change beyond `https://ads.litebits.io`, which the
  SDK already talks to. SDKs before 1.7.0 ignore `visit_url` and open the link directly.

### 4a. How creatives render

The server probes every `url` campaign (can the page be framed? what does its
`t.me` / Open Graph card say?) and sends the result with the ad as `framable`
and `card` (`{ kind: 'telegram' | 'web', title, description, image_url, handle }`
or `null`). The SDK picks the first row that matches; nothing here changes the
countdown, the badge or the Done rule.

| creative | what the user sees | tap / CTA |
|----------|--------------------|-----------|
| `creative_type: 'image'` | the image, full-bleed; the CTA (`cta_text`, default "Open") at the bottom | opens `url` externally |
| `url` to `t.me/…` (or `card.kind === 'telegram'`) | **Telegram card**: round 64 px avatar (first letter on a Telegram-blue disc when there is no photo), a "Telegram Channel" / "Telegram Bot" chip, title, `@handle` in Telegram blue, description (3 lines max), then a full-width blue "Open in Telegram" ("Open Bot" for a `…bot` handle; `cta_text` overrides) inside the card (SDK 1.8.0); the whole card is tappable. Never an iframe. | `Telegram.WebApp.openTelegramLink`, else `openLink` / `window.open` |
| `url` with `framable: true` (only ever sent inside LiteBits' own Mini App) | the advertiser's page in a sandboxed iframe, plus an outlined "Open in browser ↗" button at the bottom. If the page has not loaded after 6 s (or errors) the web card below takes over — the countdown keeps running, it is not restarted. | `openLink` / `window.open` |
| `url` with `visit_url` (SDK 1.7.0, §4b) | the web card below, never an iframe | opens `visit_url` (our visit page) instead of `url` |
| any other `url` | **web card**: the card's image (16:9, max 40 % of the screen), title and description when the server has a card, else the campaign title and the host name; the whole card is tappable; "Open" at the bottom (`cta_text` overrides). | `openLink` / `window.open` |

Only the boolean `true` frames; `false`, `null` or a missing field all mean the
web card. In your Mini App every page ad arrives with `framable: false` and renders
as the web card: we cannot see your page's Content-Security-Policy or what the
advertiser's server sends to each of your users, and a frame that is blocked still
looks "loaded" to the SDK, so the card is the version that always shows the ad. On our faucet, keeping the user inside the app (iframe or Telegram card)
instead of sending them to a browser cut abandonment by ~29%.

## 5. Fallback networks: your own accounts, your own payouts

We cannot fill every request. Instead of wiring a second SDK for
the misses, add your **own** publisher ids for other networks to the placement
(dashboard → Placements → "Fallback networks", up to 6, ordered). After a
LiteBits no-fill the SDK runs them in that order and stops at the first one
that shows. LiteBits bills nothing and pays nothing for those views.

| network    | params you enter            | what the SDK does |
|------------|-----------------------------|-------------------|
| `gigapub`  | `project_id`                | loads `ad.gigapub.tech/script?id=<project_id>`, calls `showGiga()` |
| `adsgram`  | `block_id`                  | loads `sad.adsgram.ai/js/sad.min.js`, `Adsgram.init({ blockId }).show()` — resolves on `{ done: true }` |
| `towerads` | `api_key`, `placement_id`   | loads `uslads.com/sdk/tower-ads-v4.js`, `new TowerAds({ apiKey, placementId, … }).loadAndShow()` — resolves on `onRewardEarned` or a settled `loadAndShow()` |
| `telega`   | `ad_block_uuid` (+ `token`) | loads `inapp.telega.io/sdk/v1/sdk.js`, `TelegaIn.AdsController.create_miniapp({ token }).ad_show({ adBlockUuid })`. The Telega **miniapp token** is required to build the controller; if your page already initialises `window.TelegaInAds` itself, the SDK reuses it and no `token` is needed. |
| `richads`  | `pub_id`, `app_id`          | loads `richinfo.co/richpartners/telegram/js/tg-ob.js`, `new TelegramAdsController()`, awaits `initialize({ pubId, appId })`, then `triggerInterstitialBanner()` — resolves when the creative was shown and closed. Both ids are the `pubId` and `appId` in the `initialize()` snippet RichAds gives you; neither is a secret. **Ask your RichAds manager to turn off "automatic ad display" (ads on any tap)**: once their script is initialised it listens for clicks on the whole page, and with that setting on it opens ads wherever your users tap. |

How it runs:

1. `POST /v1/ad/request` answers `204` → `nofill` is emitted (as before).
2. The chain comes from `GET /v1/ad/config?placement_id=…`, fetched **once per
   `LiteBitsAds` instance**, lazily on the first `show()`. If that fetch fails
   the SDK logs a console warning and behaves as if the placement had no
   fallbacks — it never blocks or throws on it.
3. Each step is bounded at **20 s**. The network script is loaded on first use
   (once per page), then its show call runs. The step ends as:
   - `shown` — the network reported completion/reward, or its creative is
     still on screen at the bound (the screen decides, not the promise — some
     SDKs never settle while their ad plays);
   - `no-fill` — the network declined (rejected, skipped, blocked);
   - `timeout` — nothing settled and nothing is on screen after 20 s;
   - `error` — script failed to load, SDK global missing, unknown network or
     missing params.
   Every step is reported to `POST /v1/ad/outcome` with its `network`, so the
   dashboard's fill breakdown shows LiteBits vs each of your networks.
4. Per step the SDK emits `fallback { network, outcome, error? }`. The first
   `shown` resolves `show()` with

   ```js
   { verified: false, fallback: true, network: 'gigapub', earning: 0, campaign_id: null, impression_id: null, reason: 'fallback' }
   ```

   `rewarded` is **not** emitted and no `closed` event follows: `rewarded` is
   reserved for views LiteBits verified, and the third-party network drew its
   own UI (our overlay never appears for a fallback). If every step fails,
   `show()` rejects with `NO_FILL` exactly as before.

**You pay these users from that network's own callback**, exactly as you would
if you had integrated it directly. A `fallback: true` result is a UI hint from
the browser: it tells you which network took the slot so you can, say, show a
"thanks" toast — it is not a receipt. Our signed callback (§6) never fires for
fallback views, and `verified` is always `false` on them.

Opting out: `new LiteBitsAds({ …, fallbacks: false })` skips the chain
client-side (no config fetch, no third-party script). Sandbox mode never runs
fallbacks. Only one ad is ever in flight per page — `BUSY` applies to the whole
chain — and `destroy()` during a fallback step rejects `ABANDONED` (the
third-party creative, if any, is theirs to close).

If your Mini App sets a Content-Security-Policy, allow `script-src` for the
network hosts you configured; nothing is fetched from them unless that step is
reached.

## 6. The reward callback is your receipt

Set a **callback URL** in the dashboard (Integration tab). You get a
`callback_secret` once — store it server-side. For every **verified** impression we
`POST` to that URL:

```
x-lba-timestamp:   1726000000            (unix seconds)
x-lba-event-id:    5d5c…                 (uuid, stable across retries — dedupe on this)
x-lba-delivery-id: 9f1e…                 (uuid per attempt)
x-lba-signature:   <base64url(HMAC-SHA256(callback_secret, `${timestamp}.${rawBody}`))>
Content-Type:      application/json

{ "event": "impression.verified", "event_id": "5d5c…", "placement_id": "…",
  "telegram_user_id": 123456789, "ext_user_id": "123456789",
  "campaign_id": "…", "earning": 1, "occurred_at": "2026-09-12T10:00:00Z" }
```

`telegram_user_id` (number) is the verified Telegram user id taken from
`initData` — the same id your Mini App sees in `initDataUnsafe.user.id`, so you
can credit the account directly. `ext_user_id` carries the same value as a string
for compatibility with earlier integrations.

Answer with any `2xx` within 10 s. Non-2xx or no answer → we retry after 1 min,
5 min, 30 min, 2 h, 12 h with the same `event_id`.

### Verify it (Node / Express)

```js
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.LBA_CALLBACK_SECRET;
const app = express();

// Raw body is required: the signature covers the exact bytes we sent.
app.post('/lba/callback', express.raw({ type: '*/*', limit: '64kb' }), async (req, res) => {
  const ts = Number(req.get('x-lba-timestamp'));
  const eventId = req.get('x-lba-event-id');
  const sig = req.get('x-lba-signature') || '';
  if (!ts || !eventId) return res.status(400).end();
  if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(400).end();   // replay window: 5 min

  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${ts}.${req.body.toString('utf8')}`).digest('base64url');
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).end();

  const evt = JSON.parse(req.body.toString('utf8'));
  // Idempotent: the same event_id may arrive more than once (retries).
  const fresh = await db.insertIgnore('lba_events', { event_id: eventId, payload: evt });
  if (fresh && evt.event === 'impression.verified') {
    await creditTelegramUser(evt.telegram_user_id, YOUR_REWARD_FOR_ONE_AD);   // your own amount, your own ledger
  }
  res.status(200).end();
});
```

### Rules

1. **Pay from the callback, not from the client.** `rewarded` and the resolved
   promise are UI hints from a browser you do not control. The signed callback is
   the only thing that proves the view was verified and earned.
2. **Dedupe on `x-lba-event-id`.** Retries carry the same id; credit once.
3. **Reject skew over 300 s** and always compare signatures in constant time.
4. **Keep `callback_secret` on the server.** Rotating the callback URL in the
   dashboard issues a new secret; the old one stops validating immediately.
5. **Credit by `telegram_user_id`.** It is the verified identity; frequency
   capping is keyed on it network-wide, so the same person cannot be paid twice
   for one campaign across two Mini Apps.

## 7. Sandbox and the demo page

`sandbox: true` (or the header `x-lba-sandbox: 1` on the API) returns a fixed demo
creative; `complete` answers `{ verified: true, earning: 0, sandbox: true }` and
nothing is written or billed. Sandbox does not need a connected bot and works in a
normal browser (the SDK sends `sandbox_user_id: 'demo'` when no Telegram context
exists; inside Telegram the real `initData` is sent even in sandbox). **Sandbox
never runs fallback networks.** The demo Mini App at `/demo/?key=<api key>` has
sandbox on by default, exercises every event (including `fallback`), logs
timings, prints the placement's fallback chain from `/v1/ad/config`, and has a
"Skip fallbacks" toggle (`fallbacks: false`) — open it in the browser after
`npm run build` in `sdk/`. To test a real ad or the fallback chain, open the same
page inside your bot: the banner switches to "Telegram user detected" and you
can untick Sandbox.

## 8. Telegram Mini Apps

Inside the Mini App the SDK reads `Telegram.WebApp.initData` on every `show()`,
sends `platform: 'telegram'`, opens links with `Telegram.WebApp.openLink` /
`openTelegramLink`, disables vertical swipe-to-close while the ad is open, and
fires a haptic on completion. Do not parse or forward `initData` yourself — the
SDK sends the raw string and our server does the verification.

## 9. Rendering an ad you already chose

This entry point exists for LiteBits' own faucet, which sells and bills its own
campaigns and only wants them drawn the same way as network ads. It is not part
of the publisher integration: if you are a publisher, use `show()` — `render()`
does not request, bill or pay anything, and views drawn with it never reach your
LiteBits balance or your callback.

```js
const { completed, tapped, visited, frame } = await LiteBitsAds.render(ad, {
  complete: () => bankReward(),   // required, () => Promise<{ ok: boolean }>, called when the countdown ends
  onOpen: (url) => {},            // optional, notified on every tap-through (CTA, creative, card, badge)
  onFrame: (f) => {},             // optional (SDK 1.9.0), once per framed ad: { state: 'loaded', ms } | { state: 'fallback', ms, reason }
  minSeconds: 10,                 // optional, overrides ad.min_seconds
  badgeCampaign: 'faucet',        // optional, ?p= on the badge link (default 'faucet')
  lang: 'ru',                     // optional (SDK 1.6.0), overlay language; default Telegram user → browser → 'en'
});
```

- Static method: no instance, no API key, and the SDK makes **no request of its
  own** — the caller's `complete()` is the only thing that banks anything. The one
  exception (SDK 1.7.0): an ad that carries `visit_url` is opened through the visit
  page (§4b), and after such a tap the SDK asks `<visit_url>/status` to fill `visited`.
  `visited` is what the user's device saw: a server that pays only after a visit must not
  take it on trust (the LiteBits faucet asks our server directly, server to server).
- `ad` is the same shape the network sends: `{ creative_type: 'url' | 'image',
  url, image_url, cta_text, title, min_seconds, framable, card, visit_url? }`. It renders in
  the same order and overlay as `show()` (§4, §4a), badge and countdown included.
- When the countdown ends the SDK awaits `complete()`. `{ ok: true }` shows
  "Done"; a rejection, a throw or anything else shows "Close" with "Could not
  confirm this view" so the user is never stuck. The promise resolves when the
  user taps it: `completed` is whether `complete()` said ok, `tapped` whether the
  user opened the ad's `url` (badge taps fire `onOpen` with the badge URL but do
  not count).
- The SDK opens links itself; `onOpen` is only a notification, and opening a link
  never closes the overlay or stops the countdown. Since SDK 1.7.1 `onOpen` runs
  before the link opens, so a tap beacon sent from it (use `keepalive` or
  `navigator.sendBeacon`) leaves before Telegram switches away. After a tap on the ad's `url`
  the countdown keeps counting while the advertiser is open (SDK 1.6.0), as in `show()`.
  `render()` reports no taps to our server: the host has `tapped` and `onOpen`.
- Framed ads (SDK 1.9.0): `onFrame` is called once — `{ state: 'loaded', ms }` on the
  frame's `load` event (ms since the frame was mounted), or `{ state: 'fallback', ms, reason }`
  when the 6 s watchdog (`'timeout'`) or the frame's `error` (`'error'`) drew the web card
  instead — and the result carries `frame: { state, load_ms, dwell_ms }`, `dwell_ms` being the
  time the frame was on screen while the page was visible. `frame` is absent for every other
  creative. Notification only: a throwing `onFrame` never breaks the ad.
- It shares the one-ad-at-a-time rule with `show()` (`BUSY`). It rejects `CONFIG`
  only before anything is drawn: no `complete`, or an ad with neither an http(s)
  `url` nor an `image_url`. No events are emitted.
