Живой пример
Ниже работает тот же файл, который магазины загружают с /widget/v1/cryptolike.js. Вставьте id платежа, созданного вашим сервером, — и откроется настоящая страница оплаты; сниппет повторяет ваши настройки.
Страница оплаты появится здесь, когда вы смонтируете виджет с id платежа.
События страницы
Здесь появятся сообщения ready, status, resize и error.
<div id="pay"></div>
<script src="https://95-133-236-76.sslip.io/widget/v1/cryptolike.js"></script>
<script>
CryptoLike.mount({
container: "#pay",
invoiceId: "PASTE_INVOICE_ID", // from your server: POST /v1/invoices
theme: "dark",
lang: "en",
accent: "aqua",
onPaid: function () { /* show a thank-you; fulfil on the webhook */ }
});
</script>Секрет API не попадает ни в этот сниппет, ни в любой другой код в браузере: платёж создаёт ваш сервер, странице передаётся только id.
Справочные страницы — на английском.
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 (docs/CHECKOUT-API.md) — 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 (docs/API-AUTH.md, docs/SDK.md). It gets back the invoiceidandcheckout_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, docs/WEBHOOKS.md) or onGET /v1/invoices/{id}from your server. A browser message can be forged and a page can be closed before it arrives —onPaidis for the interface, not for the order.
The official SDKs (docs/SDK.md) 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:
// 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 page3. Mount the widget on your page:
<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 (docs/WEBHOOKS.md).
The loader is ~4 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_originsofGET/PATCH /v1/account/profile). - API:
GET /v1/embed(permissionread),PUT /v1/embed(create_invoice):
{ "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 |
en |
en | ru — selects the localized page. |
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.
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, docs/CHECKOUT-API.md §6). 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'):
- The browser requests the iframe document with
Referer: https://shop.example/(the defaultstrict-origin-when-cross-originpolicy sends the origin of the embedding page). Only a canonicalscheme://host[:port]is taken from it — a host with*,;, quotes or escapes is treated as no referrer. - The site's server asks the API
GET /v1/checkout/{id}withOrigin: https://shop.example— the same read that renders the page, once per request. - The API answers
embed.allowed: truewhen that origin is on the merchant's list. Only then the document carriesContent-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.jsnotices the silence and callsonError({ code: "unavailable", timeout: true })(§4timeout). 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-ancestorsagainst 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-FROMwas never interoperable and is gone from browsers), so on an embeddable page it could only sayDENYorSAMEORIGINand contradict the CSP; browsers ignore it wheneverframe-ancestorsis present (CSP Level 2), and every browser that can run the widget supportsframe-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-referrermust setreferrerpolicy="strict-origin-when-cross-origin"on the<iframe>element (cryptolike.jsdoes).no-referrer= not embeddable. - The embedded page never navigates inside the frame (language and theme come from the URL,
return_urland "open in a new tab" links usetarget="_top"/_blank), so the referrer stays the embedding page for the life of the frame; a reload keeps it. https://pages cannot frame anhttp://page and vice versa (mixed content).- The document is served
Cache-Control: no-storewithVary: 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 |
en |
ru is served from /ru/pay/{id} — the widget redirects before the first paint, so the frame never navigates later. |
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:
"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(orlocalhostwhile 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-ancestorsfor origins that are not framing the page right now, and none at all for a document the API did not confirm (errors included); noX-Frame-Optionson 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=instead).