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

# Hosted Offer Page (No SDK)

> Present Encore offers inside a plain webview with no SDK installed, using a URL your app opens and a check your backend makes.

Encore hosts the Web SDK for you at `https://www.encorekit.com/offers`. Your app opens that
URL in a plain webview, passes its configuration in the query string, and verifies the
result from your backend. No package to install, no build step, and nothing to ship in a
client release.

Use it when installing an SDK is not on the table: a native app with no Encore integration,
a kiosk or device shell, a partner surface whose build you do not own, or a pilot you want
running this week. Everything else on web should use the
[Web SDK](/publishers/web/quickstart/install), which hands the presentation result back to
your own code.

Three routes serve the identical page, so pick whichever fits your app:

| Route                                | Notes                                                         |
| ------------------------------------ | ------------------------------------------------------------- |
| `https://www.encorekit.com/offers`   | The canonical entry point. Use this one for new integrations. |
| `https://www.encorekit.com/payrange` | A branded alias, serving the same page.                       |
| `https://www.encorekit.com/embed`    | A short alias, serving the same page.                         |

<Warning>
  **Claiming an offer replaces this page.** A webview has no second tab to open, so the
  handoff to the advertiser is a top-level navigation in the same webview. Your app owns the
  way back. Read [What your host app must get right](#what-your-host-app-must-get-right)
  before you build anything else.
</Warning>

## What your host app must get right

The page is built to work in a webview nobody configured, so there is very little to do.
These three things are the exceptions, and each one breaks an integration that does not know
about it.

### Open the page at top level, never in an iframe

Load the URL as the webview's own document.

The advertiser handoff navigates the frame the offer page is running in. Inside an iframe
that means the advertiser loads **into your iframe** rather than taking over the view, so the
user ends up on a third-party site rendered in a box inside your app, with your own chrome
still wrapped around it and no sensible way out. A top-level document is the only arrangement
the handoff is designed for.

The symptom is easy to recognize once you know it: the offer itself looks fine, and only
after a claim does the user end up stuck on an advertiser page inside a small frame. If you
see that, the page is being loaded in a frame.

### Claiming replaces the page, so own the way back

This is the one genuinely surprising behavior, and it only surfaces **after** a successful
claim, which is the worst moment to discover it. A locked webview with no back gesture and no
surrounding chrome strands the user on the advertiser's site.

<Steps>
  <Step title="Expect the webview to navigate away">
    After a claim, the webview is showing the advertiser's site rather than Encore or your app.
    That is the normal, successful path, not a failure.
  </Step>

  <Step title="Give the user a way back">
    Provide your own close or back control, either in the chrome around the webview or through
    the webview's own back stack. The offer page cannot return the user to your app, and there is
    no confirmation screen to close.
  </Step>

  <Step title="Let the navigation through">
    If your `WebViewClient.shouldOverrideUrlLoading` or your
    `WKNavigationDelegate.decidePolicyFor` restricts navigation to your own domain, the handoff is
    blocked at the last step. Allow `www.encorekit.com` and outbound advertiser domains, or pass a
    blocked URL to the system browser instead of cancelling it.
  </Step>

  <Step title="Do not tear the webview down on navigation">
    A host that closes the webview the moment the URL leaves `encorekit.com` closes it on the user
    mid-claim.
  </Step>
</Steps>

### This holds for the hosted page only

The hosted page does that work for you. If you ever hand-roll your own page around
`@encorekit/web-sdk` instead, the behavior changes underneath you.

The SDK reaches the advertiser through `window.open`, inside the user's tap on **Claim**. That
is correct in a browser and a no-op in a webview: `window.open` returns `null` in an Android
`WebView` unless the host sets `setSupportMultipleWindows(true)` **and** implements
`WebChromeClient.onCreateWindow`, and `null` in a `WKWebView` unless the host implements
`WKUIDelegate.createWebViewWith`. There is no second tab to be had, so **Claim** becomes a dead
tap. The hosted page replaces `window.open` with a stand-in that navigates the current frame,
which needs no cooperation from the host at all.

<Warning>
  So the dead tap is a consequence of **not** using the hosted page. A hand-rolled page in a
  webview must implement `onCreateWindow` and `createWebViewWith` itself, or reproduce the
  same-frame handoff. Using `/offers` costs you nothing here.
</Warning>

<Note>
  Because the advertiser is reached by a same-frame navigation rather than the SDK's
  `noopener,noreferrer` popup, the page declares a `no-referrer` policy. Your API key and your
  end user's id never travel to the advertiser in a `Referer` header. The route is also excluded
  from indexing and from the sitemap.
</Note>

## The URL contract

Every value is trimmed, and a blank value is treated as absent, so `?headline=` behaves
exactly like omitting `headline`. Values longer than 256 characters are truncated rather
than rejected.

### Required

| Parameter | Description                                                                                                                                 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey`  | Your publishable key (`pk_live_*` / `pk_test_*`), from **Settings → Project API Keys** in the [dashboard](https://dashboard.encorekit.com). |
| `userId`  | Your stable end-user identifier. It is the join key for everything: entitlements, offer attribution, and the server verification below.     |

Omit either one and the page renders a diagnostic screen naming what is missing, instead of
an offer. Build your URLs against that screen before you ship.

### Optional

| Parameter          | Default                                                             | Accepted values                                                                            |
| ------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `useCase`          | `rewardUsers`                                                       | `rewardUsers`, `reduceChurn`                                                               |
| `placement`        | `hosted_reward` for `rewardUsers`, `hosted_offer` for `reduceChurn` | A plain identifier, matching `^[a-z0-9][a-z0-9_-]{0,63}$` case-insensitively               |
| `autoPresentOffer` | on                                                                  | Send exactly `false` to suppress the automatic presentation. Any other value leaves it on. |
| `headline`         | The offer's resolved copy                                           | Any string, up to 256 characters                                                           |
| `subheadline`      | The offer's resolved copy                                           | Any string, up to 256 characters                                                           |

<Warning>
  **This page defaults to `useCase=rewardUsers`, which differs from the Web SDK's own default
  of `reduceChurn`.** The SDK's default suits a save attempt fired automatically as someone
  leaves. Nothing about this page is automatic: your app opens it at a moment of its own
  choosing, and that moment is a reward far more often than a cancellation. Pass
  `?useCase=reduceChurn` to get the churn carousel instead. What each value renders is
  documented in [The Claim Flow](/publishers/web/concepts/claim-flow#which-sheet-renders).
</Warning>

An unrecognized `useCase` or `placement` does **not** fall back to the default. The page
refuses to start and names the invalid parameter, because silently presenting the wrong
screen to someone who asked for a specific one is the harder failure to track down.

### Targeting attributes

Eleven of the SDK's [user attributes](/publishers/web/sdk-reference/set-user-attributes) are
accepted as top-level query parameters. Each carries the same meaning and the same string
type it has there:

`countryCode`, `postalCode`, `city`, `state`, `language`, `subscriptionTier`,
`monthsSubscribed`, `billingCycle`, `lastPaymentAmount`, `lastActiveDate`, `totalSessions`

Anything else goes in the `custom.` bag. `custom.plan=gold&custom.region=west` arrives as
`custom: { plan: 'gold', region: 'west' }`. Custom keys must match
`^[a-z0-9][a-z0-9_-]{0,39}$` case-insensitively, and at most 20 are kept. Keys past that cap,
and keys that are not plain identifiers, are dropped.

Parameters on neither list are ignored and never forwarded. The page reads an allowlist
rather than passing the query string through, so a parameter nobody reviewed cannot turn a
URL into an open data channel.

### Attributes the page refuses

The Web SDK accepts these attributes happily. This page does not take them from a URL:

`email`, `firstName`, `lastName`, `phoneNumber`, `dateOfBirth`, `gender`, `latitude`,
`longitude`

A query string is not a private channel. It lands in the webview's history and in hosting
access logs, and direct identifiers and precise location do not belong in either.

<Warning>
  Sending a refused attribute is not an error, and the page still opens normally, so watch for
  this: **there is no on-screen indication that the attribute was dropped.** It is simply
  absent from targeting, which surfaces later only as targeting that quietly does less.
</Warning>

If you genuinely need one of these signals, contact Encore about a short-lived signed token
rather than putting the raw value in a URL.

## Worked URLs

<CodeGroup>
  ```text Minimal theme={null}
  https://www.encorekit.com/offers?apiKey=pk_live_yourpublishablekey&userId=user-123
  ```

  ```text Reward, with targeting theme={null}
  https://www.encorekit.com/offers?apiKey=pk_live_yourpublishablekey&userId=user-123&placement=vending_kiosk&countryCode=US&subscriptionTier=gold&monthsSubscribed=14&custom.machine=kiosk-4471
  ```

  ```text Cancellation flow theme={null}
  https://www.encorekit.com/offers?apiKey=pk_live_yourpublishablekey&userId=user-123&useCase=reduceChurn&headline=Before%20you%20go
  ```

  ```text Test key, presentation suppressed theme={null}
  https://www.encorekit.com/offers?apiKey=pk_test_yourtestkey&userId=user-123&autoPresentOffer=false
  ```
</CodeGroup>

Build the URL on your server, so the key and the id are interpolated somewhere you control,
and let the platform encode the values for you:

```javascript Build the URL theme={null}
const url = new URL('https://www.encorekit.com/offers');
url.searchParams.set('apiKey', process.env.ENCORE_PUBLISHABLE_KEY);   // pk_live_...
url.searchParams.set('userId', user.id);                              // your stable id
url.searchParams.set('placement', 'vending_kiosk');
url.searchParams.set('countryCode', user.countryCode);
url.searchParams.set('custom.machine', machine.id);

openWebView(url.toString());
```

## Webview settings

Nothing here is required to make the page work. Both settings buy you better behavior.

### Turning on DOM storage

**Android `WebView` ships with DOM storage turned off.** The SDK degrades cleanly, falling
back from `localStorage` to `sessionStorage` to in-memory, so nothing crashes and nothing
looks wrong. What it costs you is that every marker the SDK keeps per user is re-derived on
each open: the measurement exposure marker, analytics deduplication markers, the cached
entitlement snapshot, and any queued events still waiting to be delivered. The most visible
effect is an inflated exposure count in reporting.

Two things are unaffected, and worth knowing so you do not go hunting for a problem that is
not there. **Identity** is stable, because `userId` arrives in the URL on every open rather
than out of storage. **Experiment cohort** is stable too, because it is derived
deterministically from that same `userId` rather than stored and looked up.

One line removes the whole issue:

<CodeGroup>
  ```kotlin Android theme={null}
  webView.settings.domStorageEnabled = true
  ```

  ```swift iOS theme={null}
  // WKWebView enables DOM storage by default. Nothing to do.
  ```
</CodeGroup>

### Load it over HTTPS

Point the webview at `https://www.encorekit.com` directly. Cohort assignment uses WebCrypto,
which browsers expose only in a secure context. Serving this page through a plain `http://`
origin silently drops every user out of measurement.

## Verify the entitlement from your server

There is no client bridge, and none is needed. The page and your app never talk to each
other. They agree on a `userId`, and your backend asks Encore what that user holds.

Call [`GET /entitlements/server`](/publishers/offers-api/reference/entitlements-server) with
the **same `userId`** you put in the URL. It is HMAC-signed, so it is a backend call only:

```javascript Backend (Node) theme={null}
import crypto from 'node:crypto';

const method = 'GET';
const path = '/publisher/sdk/v1/entitlements/server';   // the signed path: no /encore
const query = `userId=${encodeURIComponent(userId)}`;   // the same id the webview URL carried

const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
  .createHmac('sha256', process.env.ENCORE_SECRET_KEY)
  .update(`${timestamp}.${method}.${path}.${query}`)
  .digest('hex');

const res = await fetch(`https://api.encorekit.com/encore${path}?${query}`, {
  method,
  headers: {
    'X-API-Key': process.env.ENCORE_PUBLISHABLE_KEY,   // pk_live_...
    'X-Signature': signature,
    'X-Timestamp': timestamp,
  },
});

if (!res.ok) return;   // 401 = bad signature, stale timestamp, or an unresolvable key

const { verified } = await res.json();
const entitled =
  Boolean(verified.freeTrial) ||
  Boolean(verified.credits?.totalAmount) ||
  (verified.discounts?.length ?? 0) > 0;

if (entitled) grantAccess(userId);
```

<Warning>
  Gate real access on **`verified`** only. `provisional` is the immediate unlock a client shows
  while the brand-side completion is still unverified. Treat it as a courtesy, never as proof.
</Warning>

Two details cause most signature failures, and the
[endpoint reference](/publishers/offers-api/reference/entitlements-server#authentication)
covers both: you sign the path **without** the `/encore` gateway prefix, and a GET signs a
canonical query string rather than a body. Multi-app projects also send `X-Platform`.

<Tip>
  Prefer the push side where you can. The
  [Offer Completed webhook](/publishers/offers-api/reference/offer-completed-webhook) delivers
  each completion as it verifies, which beats polling for something that may complete days
  after the user closed the webview.
</Tip>

## When the page does not start

Missing or invalid parameters produce a plain screen reading **"This link isn't complete"**,
followed by the specific problem. Every missing name is listed at once, then every invalid
one, so one look is enough: `missing apiKey, userId`, or `missing userId · invalid useCase`.
That text is aimed at whoever is assembling the URL. Check it first when a webview opens to
something other than an offer.

A page that starts but presents nothing is a different, normal outcome. It means no offers
are eligible for that user right now, and it renders **"Nothing available right now"**. That
is neither an error nor a misconfiguration.

## See also

* [The Claim Flow](/publishers/web/concepts/claim-flow): what a claim does, and what the claimed result carries
* [GET /entitlements/server](/publishers/offers-api/reference/entitlements-server): the full endpoint contract for the call shown here
* [Offer Completed Webhook](/publishers/offers-api/reference/offer-completed-webhook): the push counterpart
* [Web SDK Quickstart](/publishers/web/quickstart/install): the full integration, for when you can install a package
