Two lines of HTML, on your own page.
The checkout is a custom element with a shadow root. Your stylesheet cannot reach in and break it, and it cannot reach out and restyle your site. No iframe, no redirect to somebody else's brand, and no script that takes over the page it was dropped onto.
Putting it on the page
A script tag and an element. The channel is the public identifier of one sale; without it nothing renders.
<script type="module" src="https://cdn.hibilet.com/hi-pos.js"></script>
<hi-pos channel="0193f2…" api="https://api.yourdomain.com"></hi-pos>
That is the whole integration. There is no SDK to install, no build step on your side, and nothing to initialise in JavaScript. Drop it into WordPress, Webflow, a static page or a React app and it behaves the same way, because it is a browser primitive rather than a framework component.
Nothing on this page needs a merchant token. The element only ever calls the public channel endpoints. It never sees a secret, it never computes a price it then sends back, and it never learns anything about your account beyond the branding it is told to wear.
Or don't embed it at all
If you have nowhere to put it, the same component is a page: store.html?channel=… is a complete shop you can link to from a poster or a bio. Same build, same component, same behaviour on a phone and on a desktop.
Attributes
Attributes are the entire public API. Presence-based booleans are on when present and off when absent.
| Attribute | Default | What it does |
|---|---|---|
channel | — | The public link to one sale. Required; without it nothing renders. |
api | /api | API origin. The default suits a same-origin proxy. |
layout | list | list or grid. |
density | comfortable | comfortable or compact. |
theme | auto | light, dark or auto. |
locale | browser | Sent to the API, which returns translated text. |
hide-stock | off | Hides remaining-stock counts. |
hide-quantity | off | Hides the quantity stepper. |
hide-checkout | off | Catalogue only, no path to pay. |
hide-intro | off | Drops the name, blurb and venue from the widget's own header, for a host page that already shows them. |
hide-seller | off | Drops the footer naming the seller. Only for a page that names them itself — somebody has to. |
Theming it
Colours are deliberately not attributes. They are CSS custom properties, which inherit through the shadow boundary, so you theme the checkout with an ordinary stylesheet instead of stringifying hex codes into HTML.
hi-pos {
--pos-accent: #0f766e;
--pos-radius: 12px;
}
Your account's own theme is applied as a :host rule inside the shadow root, specifically so that a rule you write on the host page still wins. Our branding is a default, never an override.
Why it fits anywhere
Every size decision is a container query, not a media query. The element declares container-type: inline-size, so it measures the space it was given rather than the browser window.
| Container width | What it becomes |
|---|---|
| under ~48rem | One column, sticky action bar, basket as a sheet |
| ~48rem and up | Two columns, basket rail always visible, no action bar |
| ~56rem and up | Products in two columns as well |
This is the difference between an embed that works and one that does not. A media query asks the window, so the same widget dropped into a 380px sidebar on a 27-inch monitor would lay itself out as a desktop shop and overflow the space it was given. Asking the container means the embed and the standalone page are one component answering the same question correctly in both places.
It is designed at 360px and allowed to grow, not shrunk down from a desktop layout. The assumption is a phone, in a queue, on mobile data, one-handed. Two steps, each one screen: catalogue, then checkout, then done. The basket and the extras are sheets over the catalogue, never screens of their own.
Links and query strings
Query parameters map to the element's attributes, so a link can carry its own configuration. Useful for a poster QR code, a partner link, or a dark-themed embed on one page and a light one on another.
store.html?channel=0193f2…&locale=tr&theme=dark&layout=grid
There is also a bundle page, bundle.html?merchant=…&slug=…, for putting several sales at one address: a season, a weekend, a venue's whole month on one link. It is deliberately not this element — a bundle has no basket, no hold and no checkout, so reusing the shop would mean a shop that cannot sell anything. Links out of it carry the API origin, locale, theme and accent forward, because a bundle embedded on your own site is exactly the case where those are not the defaults.
The payment handshake
A sale whose merchant has connected a payment provider sends the shopper to a payment page on the merchant's own account rather than settling inside the widget. Three things are worth knowing about how that is handled, because each one is a bug in most integrations.
- The basket is not completed by leaving. Stock stays held and the hold is extended to cover the payment window. The order exists when the provider's webhook reaches the API, not when the browser comes back.
- Coming back is not proof of payment.
?paid=1is a query parameter anybody can type. The widget polls the payment session and waits forsettled. - The shopper who pays and closes the tab still gets their ticket. Nothing depended on their browser returning, and their details were remembered before they left for exactly that reason.
Four outcomes, and they read differently on purpose. Settled shows the order code. Cancelled and expired leave the basket alone, because walking away from a till is not a failure. Failed means paid-but-unfulfillable, and is already refunded. Running out of patience says the payment went through and the confirmation is still coming.
Whether the card path is offered at all is decided by takes_card on the channel, which means can take a card right now: a connected account the provider is currently willing to charge on. A merchant halfway through onboarding gets the direct path instead of a button that fails at the till.
What it talks to
Only the channel endpoints, all unauthenticated, all public by design.
GET /channels/{id} the sale, its products, and what to ask
POST /channels/{id}/baskets the guest account and the basket, together
POST /v1/reservations with the basket token
GET /v1/baskets/{id}/agreement the terms this sale is sold under
POST /v1/baskets/{id}/checkout the transaction and the order code
GET /v1/my/orders this session's order history
GET /orders/{code} one order, no token — the way back
GET /p/{merchant}/bundles/{slug} the bundle page, and only that page
The server decides prices. A client may propose a quantity; it may never propose an amount.
Things that bit us
Written down because they will bite anyone building a shadow-DOM embed, not just us.
@propertydeclarations do not work inside a shadow root. They register on the document or they do nothing at all, so the element hoists them out on connect.- The embed build must define
process.env.NODE_ENV. Without itprocessis undefined in the browser and React's development build ships alongside the production one. Adding the define took the bundle from 630 kB to 241 kB. - Container queries, not viewport queries. Covered above, and it is the one that produces the most confusing bug reports: the widget is "broken on desktop" only when it is in a narrow column.
Stuck on something not covered here? info@hibilet.com reaches somebody who has read the source.