Skip to content

Live example

The loader below is the same file merchants load from /widget/v1/cryptolike.js. Paste an invoice id your server created and it mounts the real payment page; the snippet follows your choices.

From your server: the id of POST /v1/invoices (or the last part of checkout_url). We do not create demo payments here.

Theme
Language
Accent

The payment page appears here once you mount the widget with an invoice id.

Events from the page

ready, status, resize and error messages show up here.

Your snippet
<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>

Your API secret never goes into this snippet or any other browser code: your server creates the payment, the page receives only its 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 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, docs/WEBHOOKS.md) 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 (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:

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

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 (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_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 en en | ru — selects the localized page.
accent aqua lime | aquabrand 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, 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'):

  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)

text
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:

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= instead).