Source: https://cryptolike.net/docs/widget

# Checkout widget (embedded payment page)

The hosted payment page (`https://cryptolike.net/pay/{invoice_id}`) can also live inside your own
checkout, in an `<iframe>`. Everything the embedded page shows is the same public payment data the
hosted page shows ([checkout API reference](https://api.cryptolike.net/docs)) — nothing else, and nothing secret.

## 1. Where the secret lives — read this first

**Your API secret never goes to a browser.** Not in `cryptolike.js`, not in a data attribute, not
in a "public" key of any kind.

- Your **server** creates the payment: `POST /v1/invoices`, signed with your API key
  ([Authentication](https://cryptolike.net/docs/auth), [SDKs](https://cryptolike.net/docs/sdk)). It gets back the invoice `id` and `checkout_url`.
- Your **page** receives only the invoice `id` (or the URL) and embeds `/pay/{id}?embed=1`.
  The browser talks to `/v1/checkout/*` — public, unauthenticated routes where the invoice id
  is the only capability. There is nothing to sign in the browser and nothing to leak.
- **Fulfil the order on the webhook** (`invoice.paid`, [Webhooks](https://cryptolike.net/docs/webhooks)) or on
  `GET /v1/invoices/{id}` from your server. A browser message can be forged and a page can be
  closed before it arrives — `onPaid` is for the interface, not for the order.

The official SDKs ([SDKs](https://cryptolike.net/docs/sdk)) refuse to run in a browser for this reason. If a snippet you find
asks you to put `ck_live_…` and the secret into a `<script>`, it is wrong: anyone loading your page
could create payments and read your data as you.

## 2. Quickstart

**1. Allow your origin.** Cabinet → **Settings → Integration → Allowed origins**: add
`https://shop.example` (the origin of the page that will hold the iframe). Rules and the API door
are §3.

**2. Create the payment on your server** and hand the page only the id:

```js
// Node, @cryptolike/sdk — runs on your server, never in the browser
import { CryptoLike } from "@cryptolike/sdk";

const api = new CryptoLike(); // CRYPTOLIKE_API_KEY_ID + CRYPTOLIKE_API_SECRET from the environment
const invoice = await api.invoices.create(
  { amount: "49.90", currency: "USD", order_id: "10042" },
  "order-10042", // Idempotency-Key
);
res.render("checkout", { invoiceId: invoice.id }); // only the id reaches the page
```

Without `coin_id` the payer picks the coin inside the widget: the list shows every coin you offer
for invoices (cabinet → Coins: on and «available for invoices»), each with an informative
`≈` amount; the exact amount and rate are priced again by the server and fixed when the payer
picks (if the rate moved in between, the new rate applies). To offer only some coins on
this payment, send `allowed_coin_ids` (ids from `GET /v1/currencies`) with the invoice — the
widget lists only those ([checkout API reference](https://api.cryptolike.net/docs).1, `POST /v1/invoices` in [OpenAPI spec](https://api.cryptolike.net/docs/openapi.yaml)).

**3. Mount the widget on your page:**

```html
<div id="pay"></div>
<script src="https://cryptolike.net/widget/v1/cryptolike.js"></script>
<script>
  CryptoLike.mount({
    container: "#pay",
    invoiceId: "{{ invoiceId }}",
    theme: "dark",
    lang: "en",
    onStatus: function (m) { console.log(m.status, m.isFinal); },
    onPaid: function () { showThanks(); },      // interface only — fulfil on the webhook (§1)
  });
</script>
```

**4. Fulfil on the webhook.** `invoice.paid` → mark the order paid ([Webhooks](https://cryptolike.net/docs/webhooks)).

The loader is about 5 KB, has no dependencies and adds no cookies, storage or third-party requests to
your page. ES module build: `https://cryptolike.net/widget/v1/cryptolike.esm.js`
(`import { mount } from "…"`). Pin a build if you prefer:
`https://cryptolike.net/widget/v1/cryptolike-0.1.0.js` (immutable; the unversioned URL is cached
for an hour and follows the latest v1 loader).

## 3. Allow your origins

By default the payment page cannot be framed at all and `/v1/checkout/*` answers no cross-origin
browser request. To embed, list the origins of your pages:

- **Cabinet:** Settings → Integration → *Allowed origins* (roles owner, admin —
  `embed_allowed_origins` of `GET/PATCH /v1/account/profile`).
- **API:** `GET /v1/embed` (permission `read`), `PUT /v1/embed` (`create_invoice`):

```json
{ "allowed_origins": ["https://shop.example", "https://checkout.shop.example:8443", "http://localhost:3000"] }
```

Rules (the API refuses anything else with `400 validation_error`, `details.field =
allowed_origins` / `embed_allowed_origins`, and the message quotes the entry):

| Rule | Why |
|---|---|
| An **origin** only: `scheme://host[:port]` — no path, query, fragment or credentials. | The browser compares origins, not URLs. |
| `https://` — `http://` is accepted for `localhost`, `127.0.0.1` and `[::1]` only (your dev stand). | A payment surface framed over plain HTTP can be rewritten on the way. |
| Exact host, **no wildcards** (`*.shop.example` is refused); punycode for internationalised names. | Exact match is what the CSP and the CORS check do; wildcards widen it silently. |
| At most **20** origins; entries are lower-cased, a default port (`:443`, `:80`) is dropped, duplicates are merged; the list comes back normalised and sorted. | The stored form is exactly the `Origin` header a browser sends. |
| `[]` switches embedding off. | — |

A change is live on the payment page within **2 seconds** (the view cache). Every change is
written to the team security log (`profile_updated` from the cabinet, `embed_origins_changed`
with the key id from the API).

## 4. Options of `CryptoLike.mount`

| Option | | |
|---|---|---|
| `container` | **required** | Element or CSS selector; the iframe is appended to it. Give it at least 320 px. |
| `invoiceId` | **required** (or `checkoutUrl`) | The invoice UUID from your server. |
| `checkoutUrl` | | `checkout_url` from your server — the id and the origin are read from it. |
| `origin` | `https://cryptolike.net` | Only for a stage or self-hosted deployment. Messages from any other origin are ignored. |
| `theme` | follows the payer's OS | `light` \| `dark`. |
| `lang` | the payer's browser | `en` \| `ru` \| `es` — selects the localized page. Not set: the page follows the browser (`Accept-Language`, `es-MX` → Spanish, else English). |
| `accent` | `aqua` | `lime` \| `aqua` — **brand tokens only**, no free colours. |
| `height` | `560` | Starting height in px, until the page reports its own. |
| `title` | localized | Accessible name of the iframe. |
| `timeout` | `15000` | Milliseconds to wait for the page's first `ready` / `error`. A frame the browser refused (§6) is silent; when the time runs out `onError` gets `{ code: "unavailable", timeout: true }` once. `0` disables it. |
| `onReady`, `onStatus`, `onPaid`, `onExpired`, `onResize`, `onReturn`, `onError` | | §5. |

`mount` returns `{ iframe, invoiceId, origin, url, destroy() }` — call `destroy()` when your
checkout step unmounts (it removes the iframe and the message listener). Bad options throw a
`TypeError` at once (unknown `theme`/`lang`/`accent`, an id that is not a UUID, a missing
container) — the widget never silently falls back.

The iframe carries `referrerpolicy="strict-origin-when-cross-origin"` (§6 needs the referrer) and
`allow="clipboard-write"` (the payer copies the address). It is `width: 100%`, borderless and
resizes itself; your page controls the slot.

## 5. Events (`postMessage`, contract v1)

The embedded page posts **only to the embedding origin** (`new URL(document.referrer).origin`) —
never `"*"`. Without a referrer it posts nothing. `cryptolike.js` accepts a message only when it
comes from the CryptoLike origin, from that very frame, and names that invoice; if you listen
yourself, do the same.

```ts
type WidgetMessage = {
  source: "cryptolike";
  version: 1;
  invoiceId: string;
  ts: string;                  // ISO-8601, when the page posted it
  type: "cryptolike:ready" | "cryptolike:status" | "cryptolike:resize" | "cryptolike:return" | "cryptolike:error";
  // type = cryptolike:status
  status?: "new" | "pending" | "paid" | "underpaid" | "expired" | "cancelled";
  isFinal?: boolean;
  paidAmount?: string;         // decimal strings in the invoice's asset ("0" before a coin is chosen)
  remaining?: string;
  ticker?: string | null;
  // type = cryptolike:resize
  height?: number;             // content height in CSS pixels
  // type = cryptolike:return
  returnUrl?: string;
  // type = cryptolike:error
  code?: "validation_error" | "not_found" | "unavailable";
  timeout?: true;              // set by the loader, not the page: the frame stayed silent (§4 `timeout`)
};
```

| Event | Callback | When |
|---|---|---|
| `cryptolike:ready` | `onReady` | The page rendered its first view. |
| `cryptolike:status` | `onStatus`, `onPaid`, `onExpired` | On ready and on every change of `status` / `isFinal` (the same changes the page gets over SSE, [checkout API reference](https://api.cryptolike.net/docs)). `onPaid` / `onExpired` fire once. |
| `cryptolike:resize` | `onResize` | On ready and whenever the content height changes; the loader sets the iframe height itself. |
| `cryptolike:return` | `onReturn` | The payer opened the merchant's `return_url` (the page opens it in the top window, `target="_top"`) — a notification, e.g. to stop a poll. |
| `cryptolike:error` | `onError` | Once, instead of `ready`: an embed parameter is invalid (`validation_error`, the frame shows the state and a link to the hosted page). The loader itself reports `unavailable` with `timeout: true` when the frame said nothing within `timeout` — the browser refused to frame the page (§6: origin not on the list, no referrer, an unknown payment, the view could not be read). Show your own "Open the payment page" link then. |

Addresses and exact amounts are not repeated to the parent — the widget shows them; your server
reads them from the API or the webhook. Parent → page messages: none in v1. The contract is
versioned (`version`); additive fields may appear within v1, anything else bumps the version.

## 6. Framing: CSP `frame-ancestors` and the referrer

**Rule: a document is framable only by an origin the API confirmed for this invoice's merchant.**
The site decides it on the server, per request, for `/pay/{id}` only (every other page of the site
is `frame-ancestors 'none'`):

1. The browser requests the iframe document with `Referer: https://shop.example/` (the default
   `strict-origin-when-cross-origin` policy sends the origin of the embedding page). Only a
   canonical `scheme://host[:port]` is taken from it — a host with `*`, `;`, quotes or escapes is
   treated as no referrer.
2. The site's server asks the API `GET /v1/checkout/{id}` with `Origin: https://shop.example` —
   the same read that renders the page, once per request.
3. The API answers `embed.allowed: true` when that origin is on the merchant's list. Only then the
   document carries `Content-Security-Policy: … frame-ancestors 'self' https://shop.example`.

| The API answered | Allowed origin | Any other origin / no referrer |
|---|---|---|
| the view (200) | `200`, the payment, `frame-ancestors 'self' <origin>` | **`403`**, `frame-ancestors 'none'` — card "This payment cannot be shown on this page." + **Open the payment page**, no invoice data in the document |
| a refused embed parameter (400) | `400`, the parameter card, `'self' <origin>` (a second, parameter-free read confirms the origin) | `403`, `'none'` |
| unknown payment (404) | `404`, `'none'` — there is no merchant and no list to confirm against | `404`, `'none'` |
| no answer (429, 5xx, timeout) | `200` error card, `'none'` — nothing was confirmed | same |

Consequences:

- **In a refused frame the browser shows nothing** (its own "refused to connect"), and the page
  cannot post anything. `cryptolike.js` notices the silence and calls `onError({ code:
  "unavailable", timeout: true })` (§4 `timeout`). The 403 card is what a person sees who opens an
  embed URL directly or from a page that is not allowed: a link to the hosted payment page,
  opened in the top window — never the payment itself.
- **An error document is never framable for an unconfirmed origin.** It used to be, "because it
  carries no invoice data" — but the transport-error card has *Retry*, and a Retry that loaded the
  view into that same document rendered the payment inside a frame nobody allowed (REVIEW_T14 B1).
  Now the error card is `'none'` like everything else, and in embed mode *Retry* reloads the
  document, so the decision is taken again for what is about to be shown.
- **The header names one origin — the one framing the page right now — not the whole list.** Every
  origin in it comes from the merchant's list (the API confirmed it), and a browser checks
  `frame-ancestors` against the actual ancestors, so listing the other shops would allow nothing
  extra; it would only publish the merchant's other sites and dev stands to anyone holding an
  invoice id. The allowlist stays behind authentication (§3).
- **No `X-Frame-Options`.** It has no allowlist form (`ALLOW-FROM` was never interoperable and is
  gone from browsers), so on an embeddable page it could only say `DENY` or `SAMEORIGIN` and
  contradict the CSP; browsers ignore it whenever `frame-ancestors` is present (CSP Level 2), and
  every browser that can run the widget supports `frame-ancestors`. One mechanism, one decision.
  (The API and the cabinet/admin SPAs, which are never framed, keep both headers — both say "no".)
- The iframe must send its referrer origin. The default policy does; a page under
  `Referrer-Policy: no-referrer` must set `referrerpolicy="strict-origin-when-cross-origin"` on
  the `<iframe>` element (`cryptolike.js` does). `no-referrer` = not embeddable.
- The embedded page never navigates inside the frame (language and theme come from the URL,
  `return_url` and "open in a new tab" links use `target="_top"` / `_blank`), so the referrer
  stays the embedding page for the life of the frame; a reload keeps it.
- `https://` pages cannot frame an `http://` page and vice versa (mixed content).
- The document is served `Cache-Control: no-store` with `Vary: Referer`: the policy names *your*
  origin, so no proxy or CDN in front of your page may reuse it for someone else's payer.

## 7. The embed URL (if you build the iframe yourself)

```
https://cryptolike.net/pay/{invoice_id}?embed=1&theme=dark&lang=en&accent=lime
```

| Parameter | Values | Default | |
|---|---|---|---|
| `embed` | `1` \| `true` (`0` / `false` = hosted) | hosted | The page drops its header/footer, fits the frame and reports its height (§5). |
| `theme` | `light` \| `dark` | follows `prefers-color-scheme` | Both themes are the brand tokens. |
| `lang` | `en` \| `ru` \| `es` | the browser language | `ru`/`es` are served from `/ru/pay/{id}`, `/es/pay/{id}` (es = neutral Latin-American Spanish, es-419) — the page redirects before the first paint, so the frame never navigates later. Without `lang` the same redirect follows `Accept-Language` (first language we have, by primary subtag); the hosted link accepts `?lang=` too. |
| `accent` | `lime` \| `aqua` | `aqua` | **Brand tokens only** — no free colours (`accent=%23ff0000` is a `400`). |

Unknown parameters (`utm_*`, …) are ignored. A known parameter outside its vocabulary, or given
twice, is refused with `400 validation_error` and `details.field` naming it; the page then shows
the error state with the recovery action "Open the payment page" (the hosted URL without
parameters always works) and posts `cryptolike:error`.

## 8. `embed` in the public view

Every `GET /v1/checkout/{id}` (and each SSE `status` event) carries:

```json
"embed": { "allowed": true, "mode": "embed", "theme": "dark", "lang": null, "accent": "lime" }
```

| Field | |
|---|---|
| `allowed` | The request's `Origin` header is on the merchant's allowlist. `false` without an `Origin` (the hosted page's own same-origin requests, curl). **The list itself is never returned.** |
| `mode` | `hosted` \| `embed`, from `?embed`. |
| `theme`, `lang`, `accent` | The validated parameters; `null` when not given. |

CORS on `/v1/checkout/*` follows the same decision: an allowlisted `Origin` gets
`Access-Control-Allow-Origin: <that origin>` (+ `Access-Control-Expose-Headers`; `Vary: Origin`
always; **no credentials** — the routes are cookieless); a preflight `OPTIONS` gets `204` with
`Access-Control-Allow-Methods: GET, POST, OPTIONS`, `Access-Control-Allow-Headers: Content-Type,
X-API-Version`, `Access-Control-Max-Age: 600`. Any other origin gets no CORS headers and the
browser refuses the response. A `429` from the per-IP rate limit (60/min) is answered before the
invoice is looked up and therefore carries no CORS headers — treat a failed cross-origin read as
"retry in 5 s", then fall back to opening the hosted page.

## 9. Checklist and troubleshooting

- [ ] The secret is only on your server; the page holds the invoice id and nothing else (§1).
- [ ] The order is fulfilled on the webhook, not on `onPaid` (§1).
- [ ] The embedding origin is on the allowlist, exactly as the browser sends it (§3).
- [ ] The page is served over `https` (or `localhost` while you develop).
- [ ] The container is at least 320 px wide; `destroy()` runs when the step unmounts (§4).

| Symptom | Cause |
|---|---|
| The frame stays blank, the console says the page "refused to connect" / `frame-ancestors`; `onError` gets `timeout: true`. | The embedding origin is not on the allowlist, or the page sends no referrer (`Referrer-Policy: no-referrer`) — §3, §6. Opening the iframe URL directly shows "This payment cannot be shown on this page." (403) — expected: a direct visit has no allowed referrer. |
| No callbacks fire, the frame renders. | The parent listens on another origin than the widget was pointed at (`origin` option), or a second `mount` replaced the listener. Check `event.origin` in your own listener. |
| The frame renders "The embed settings are not valid." | A `theme`/`lang`/`accent` outside its vocabulary — §7. |
| The height does not follow the content. | The iframe is styled with a fixed `height !important`, or a parent has `overflow: hidden` on a fixed-height box. The loader sets `style.height` on every `cryptolike:resize`. |
| Everything works locally, not in production. | `http://` origins are allowed for localhost only; a production page must be `https` (§3). |

## 10. What is deliberately not here

- No API secret, key id or signature in the browser (§1). No allowlist in any public response.
- No `frame-ancestors` for origins that are not framing the page right now, and none at all for
  a document the API did not confirm (errors included); no `X-Frame-Options` on the site (§6).
- No wildcard origins, no `http://` for real hosts, no free accent colours.
- No parent → page commands in v1; no addresses over `postMessage` — the widget shows them, your
  server reads them.
- No cookies from the framed page either: the embedded document sets none (the hosted pages
  remember the language in a cookie, the frame takes it from `?lang=` or the browser instead).
