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.
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)
| Option | Type | Required | Description |
|---|---|---|---|
paymentId | string (UUID v4) | Required | The capability token returned by POST /ecomm/api/v1/pay on the merchant's backend. |
onSuccess | () => void | Optional | Fires 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?) => void | Optional | Fires once when status becomes FAILED, DECLINED, or EXPIRED. |
locale | 'ro' | 'ru' | 'en' | Optional | Widget UI language. Falls back to navigator.language, then to 'ro'. |
baseUrl | string | Optional | Override the API base URL (sandbox or partner endpoint). |
pollIntervalMs | number | Optional | Override 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)
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"
}
404for an unknownpaymentId— generic body, no leak (anti-enumeration).429on 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 perpaymentId.
Idempotency on /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
POST /pay with the secret key. The browser receives only paymentId — never the amount as input. The buyer cannot tamper with the price.
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.
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.
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.
| Channel | Webview | Snippet runs? | Notes |
|---|---|---|---|
| Merchant website | Browser | Yes — as is | Primary use case. |
| Telegram | Mini App | Yes — same code | Add the Telegram Mini App adapter to close the webview on terminal states. |
| Instagram & TikTok | Link-out only | Yes — on the landing | From bio / Live / Shop link to a miaPOS landing page that runs the same snippet. |
| None (Meta) | No in-chat | Backend 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.
onSuccessfires once. - failed / declined — friendly retry.
onFailfires 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
- Add
https://js.miapos.euto your CSPscript-src. Pin with SRI. - Provide the per-merchant allowed origin(s) — public embed endpoints use a per-merchant CORS allowlist, not a wildcard.
- Always send
Idempotency-KeyonPOST /pay. - Treat the RSA-signed webhook as the source of truth for the order; do not finalise from the browser callback.
- Verify the webhook signature (see Signature verification).