Docs · Integration guide

Embed checkout

A drop-in JavaScript snippet that accepts instant bank payments inline — on the merchant's own page, inside a Telegram Mini App, or on a landing page used for Instagram and TikTok link-out. Three lines of code, no card form, no PCI.

AudienceDeveloper
DifficultyBeginner
Updated2026-06-10

Overview

The Embed checkout is an additive extension of the existing E-comm API. The base protocol (/token, /pay, /payment/{id}, /public-key, RSA callback) and the redirect plugins (WooCommerce, OpenCart, CS-Cart, PHP SDK) are unchanged. The embed track adds a thin, browser-safe extension that sits next to the base — same model as Stripe API + Stripe.js.

The architectural shape is closer to Pix or UPI (QR + bank-app deep-link + status poll) than to Stripe Elements. There are no card fields, no PAN-isolating iframes, no 3DS.

When to use Embed vs Hosted

Both modes are touchpoints of the same IP rail. They are complementary, not competing.

Use Hosted checkout when…Use Embed checkout when…
The merchant has no front-end developer, sends invoices, ships QR codes on receipts, or just wants a link in a chat. The existing WooCommerce / OpenCart / CS-Cart plugins and the payment-link flow fit here. The merchant owns the checkout page or the chat. Brand continuity, conversion at checkout, and reuse across web + Telegram Mini App + social landings matter.

See the E-commerce product page for the side-by-side narrative.

Quickstart

The merchant's backend creates the payment with the amount fixed server-side, then returns the paymentId to the merchant's page. The browser only ever holds an opaque paymentId — a capability token, not a price.

<!-- 1. Load the versioned snippet from the miaPOS CDN -->
<script src="https://js.miapos.eu/v1/"></script>

<!-- 2. Drop in a mount target -->
<div id="mia-pay"></div>

<!-- 3. Mount the widget with the paymentId your server created -->
<script>
  miaPOS.mount('#mia-pay', {
    paymentId,                       // created server-side via POST /pay
    onSuccess: () => location = '/thank-you',
    onFail:    (e) => console.warn(e.reason),
    locale:    'ro',                 // 'ro' | 'ru' | 'en'; falls back to navigator.language
  });
</script>

The widget renders the QR (desktop) or a "Pay in bank app" deep-link button (mobile), polls the public status endpoint, and fires onSuccess when the bank confirms. The widget lives inside its own Shadow DOM, so the merchant's CSS cannot leak in or out.

JavaScript API

miaPOS.mount(selector, options)

OptionTypeRequiredDescription
paymentIdstring (UUID v4)RequiredThe capability token returned by POST /ecomm/api/v1/pay on the merchant's backend.
onSuccess() => voidOptionalFires once when the public status becomes SUCCESS. Use it to redirect the buyer's tab. Do not use it to finalise the order — the source of truth is the webhook on your backend.
onFail(reason?) => voidOptionalFires once when status becomes FAILED, DECLINED, or EXPIRED.
locale'ro' | 'ru' | 'en'OptionalWidget UI language. Falls back to navigator.language, then to 'ro'.
baseUrlstringOptionalOverride the API base URL (sandbox or partner endpoint).
pollIntervalMsnumberOptionalOverride the initial poll interval. The widget uses backoff by default.

Returns: { destroy(): void } — call destroy() to tear the widget down (e.g. on SPA route change).

Public status endpoint (additive)

GET/ecomm/api/v1/checkout/{paymentId}

Public, no JWT. The paymentId is the capability — anyone holding it can read the public payment status, but nothing more.

Response 200

{
  "paymentId":    "8f3a...-uuid",
  "status":       "PENDING|SUCCESS|FAILED|DECLINED|EXPIRED",
  "amount":       499.00,
  "currency":     "MDL",
  "qrPayload":    "<EMV/MIA QR string>",
  "rtpDeepLink":  "miapay://pay?...",
  "expiresAt":    "2026-06-22T10:05:00Z"
}
  • 404 for an unknown paymentId — generic body, no leak (anti-enumeration).
  • 429 on rate-limit.
  • No payer PII in the response — no name, no phone, no email. PII, if any, only travels in the RSA-signed callback to the merchant's backend.
  • Cache-Control: no-store. Rate-limited per IP and per paymentId.

Idempotency on /pay

POST/ecomm/api/v1/pay

Add an Idempotency-Key header on payment creation. The first call executes and stores {key → response}. A repeat with the same key returns the stored response, no new payment is created. TTL is approximately 24h.

POST /ecomm/api/v1/pay
Authorization: Bearer <s2s JWT>
Idempotency-Key: 9a2e34d1-...-...
Content-Type: application/json

{ "amount": 499.00, "currency": "MDL", "orderId": "ORD-12345", ... }

Combined with inbound status dedup (by paymentId / TxId), retries / double-clicks / repeated webhooks never double-charge.

Security model

Server-authoritative amount. The amount is fixed on the merchant's backend when it calls POST /pay with the secret key. The browser receives only paymentId — never the amount as input. The buyer cannot tamper with the price.
Webhook is the source of truth. The browser onSuccess callback is only a UI hook for redirecting the buyer's tab. Order finalisation happens on the merchant's backend when it receives and verifies the RSA-signed callback. Closing the buyer's tab never loses the order.
Capability-scoped, no secrets in the browser. paymentId is a UUID v4 (~122 bits of entropy) and is the only credential the browser holds. There are no JWTs, no API keys, no cookies. credentials: 'omit' on the public endpoint.
CSP & SRI on the merchant page. Add https://js.miapos.eu to your script-src. Pin the loader with Subresource Integrity (SRI) when you go to production. The CDN serves immutable, versioned paths under /v1/.

Channel reuse

The snippet's core is channel-agnostic by design — no hard window.location redirects, no domain-cookie coupling. The same module runs everywhere a webview does.

ChannelWebviewSnippet runs?Notes
Merchant websiteBrowserYes — as isPrimary use case.
TelegramMini AppYes — same codeAdd the Telegram Mini App adapter to close the webview on terminal states.
Instagram & TikTokLink-out onlyYes — on the landingFrom bio / Live / Shop link to a miaPOS landing page that runs the same snippet.
WhatsAppNone (Meta)No in-chatBackend reused unchanged via a CTA link to a hosted checkout page, or via WhatsApp Flows for declarative flows.

States and lifecycle

  • loading — skeleton render while the first poll resolves.
  • pending — QR (desktop) or "Pay in bank app" deep-link (mobile) + amount + status indicator.
  • success — green ✓ + amount. onSuccess fires once.
  • failed / declined — friendly retry. onFail fires once.
  • expired — QR TTL elapsed; the widget offers a "refresh" affordance.

The widget pauses polling when the tab is hidden (visibilitychange) and resumes when it returns.

Errors

The public endpoint returns errors in the standard envelope (see Errors). For unknown paymentId, the body is generic — by design — to prevent enumeration. The widget shows a friendly "payment unavailable" state.

Going to production

  1. Add https://js.miapos.eu to your CSP script-src. Pin with SRI.
  2. Provide the per-merchant allowed origin(s) — public embed endpoints use a per-merchant CORS allowlist, not a wildcard.
  3. Always send Idempotency-Key on POST /pay.
  4. Treat the RSA-signed webhook as the source of truth for the order; do not finalise from the browser callback.
  5. Verify the webhook signature (see Signature verification).