What it is
A self-contained swap card you drop onto your page. It is served from
https://v2.swopus.com and scoped to your token: whatever the
user pays with, every route passes through a pair that includes it, so nobody wanders
off into unrelated markets.
The card shows:
- You send — amount plus a token selector, limited to tokens that route to yours.
- You receive — the live quote. In
buymode the output is locked to your token;swapmode unlocks the buy⇄sell flip. - Rate, Min received after slippage, and the current slippage.
- Price impact and the route the trade takes, venues included —
GOHAN → KLV → USDC → USDT · Swopus v2 + BitcoinMe (split). Users can see the order was spread across venues to get the price rather than taking it on faith. - One Connect wallet / Swap button, and on success the shortened transaction hash linked to the explorer.
Hidden on purpose: app navigation, route diagnostics, the settings modal, trade history, and the Swopus confirmation dialog. The wallet is the only confirmation step the user sees.
Live reference. The “Buy BLOCK” button on
blockdao.io is this exact integration — open it to see
the widget, the theming and the wallet flow on a real partner site. Examples below use
a YOUR-TOKEN placeholder so nothing ships with someone else's asset id by
accident.
Before you start
Quotes work in any browser. Signing needs a Klever Wallet on your page: a desktop browser with the Klever extension, or mobile inside the Klever app's dApp browser. In a plain mobile browser the widget still quotes, but there is no wallet to reach: within a couple of seconds the CTA reads No wallet detected and the card explains where to open the page. Either link mobile users into the Klever app, or treat mobile as quote-only.
That state replaces your ctaConnectText on the button — a label promising a
connection that cannot happen is worse than a plain answer. Your theming still applies,
and if a wallet turns up late (a slow browser extension) the notice clears on its own and
the normal connect label returns.
This is the one constraint that decides where you place the widget, so it is stated once here and referenced rather than repeated.
What it does not do
Three things the widget leaves to you or does not implement yet. Read them before you build around it — the first is a decision you are making on your users' behalf.
- Your users cannot change slippage. The settings modal is hidden, so
the
slippageyou pass (default0.5%) is fixed for everyone using your embed. It bounds their worst case through Min received — pick it deliberately. - An account switch produces no event. Start, success and failure are all covered (see Events), but a user swapping wallets mid-session is not announced.
- Events are not proof. See Events — never grant anything of value on an event alone.
Quick start
Use the script loader. It builds the iframe, auto-resizes it to the content, and — the part that matters — bridges your page's Klever Wallet into the widget, so signing works even though the widget lives on another domain.
<div id="swopus-widget"></div>
<script
src="https://v2.swopus.com/embed.js"
data-target="#swopus-widget"
data-project-token="YOUR-TOKEN"
data-token-in="KLV"
data-mode="swap"
data-slippage="0.5"
></script>
That is the whole integration. Keep the wrapper visually neutral — reserve width
(≤ 420px) and min-height: 520px, and don't put your own background or
border around the mount node. The widget draws its own card and paints the full frame
with your background, so there is no empty band or off-colour edge.
Only data-project-token is required. data-mode defaults to
buy; the example passes swap because most partners want the
flip available.
Branding
Add branding attributes to the same tag — colours, ink, corners, outline weight and a brand font:
data-accent="#CDFF00"
data-bg="#0A0A0A"
data-surface="#1A1A1A"
data-border="#2A2A2A"
data-radius="16"
data-border-width="2"
data-text-muted="#8B93A1"
data-font-url="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600&display=swap"
data-font="Archivo, 'General Sans', system-ui, sans-serif"
data-cta-text="Buy YOUR-TOKEN now"
data-token-chip-style="pill" Four things decide whether this looks intentional, plus one check that catches the rest:
- Depth needs two layers.
bgis the deepest,surfacethe raised cards. Passing both reads as designed; passing one gives you a flat block. - Light palettes work out of the box. On a light
bg/surfacethe widget derives dark ink automatically — the same contrast logic the CTA uses foraccent. Passtextonly to force an exact brand ink, and note the example in the table is a dark ink (#0A0A0A) because that is the case where you would need it. - Fonts come in pairs.
fontUrlloads the stylesheet,fontselects the family — either alone is a silent no-op. End your stack with a real fallback. - The token picker is themed too. The pop-up asset sheet inherits your
colours, radius and font. It asks for extra height while open, so expect a
resizepair on open and close.
Then check four things: corners and outlines match across cards, inputs,
both token chips and the open token sheet; text stays readable on light palettes; the
token sheet renders your font rather than a serif fallback (a serif means the family
never loaded); and if you want the frame to hug the card, set
data-min-height="412" — the default 520 leaves a band of background below
the ~410px disconnected card.
You cannot reach widget internals with your CSS — the iframe boundary is deliberate. Everything themeable is a parameter; if something you need isn't exposed, ask rather than fighting the frame.
Parameters
Every option works three ways: a query param on the widget URL, a data-*
attribute on the loader, or a camelCase key in mount(). An invalid value
falls back to the default instead of erroring.
Core
| Param | Loader attribute | Default | Description |
|---|---|---|---|
projectToken | data-project-token | required | Your token. Scopes every trade to routes that include it. Aliases: assetId, project_token. |
tokenOut | data-token-out | projectToken | Initial output. In buy mode it is locked to the project token, so this only matters in swap mode — where it sets which side the user starts on. Alias: to. |
tokenIn | data-token-in | KLV | Default token the user pays with. Alias: from. |
mode | data-mode | buy | buy locks the output to your token; swap unlocks the flip. |
router | data-router | best route | off forces the single direct pool instead of best-execution routing. |
slippage | data-slippage | 0.5 | Tolerance in percent. Fixed for every user of your embed — see What it does not do. |
autoConnect | data-auto-connect | on | off stops the widget asking the wallet to connect on open. |
defaultAmount | data-default-amount | — | Pre-fills the pay amount. Alias: amount. |
partner | data-partner | — | Attribution key, forwarded for the upcoming partner dashboards. |
Branding
| Param | Loader attribute | Example | Description |
|---|---|---|---|
accent | data-accent | #CDFF00 | CTA and active-state colour. Button ink auto-picks dark or light for contrast. |
flipIconColor | data-flip-icon-color | #CDFF00 | Flip-icon tint. Defaults to accent. |
bg | data-bg | #0A0A0A | Page background — the deepest layer. |
surface | data-surface | #1A1A1A | Card and input surface — the raised layer. |
border | data-border | #2A2A2A | Internal border colour. |
borderWidth | data-border-width | 2 | Outline weight in px, up to 6. Applies to the card and both token chips. |
radius | data-radius | 16 | Corner radius in px, up to 40. 0 squares the whole widget. |
text | data-text | #0A0A0A | Primary ink. Needed only to override the automatic choice — the example is dark ink for a light palette. |
textMuted | data-text-muted | #5A6780 | Secondary ink for labels and sublines. |
font | data-font | Archivo, system-ui, sans-serif | Font stack inside the widget. Pair with fontUrl. |
fontUrl | data-font-url | Google Fonts URL | Loads your brand face. Accepted only over https, from host fonts.googleapis.com exactly, path starting /css; anything else is dropped. |
ctaText | data-cta-text | Buy now | Connected CTA label, max 40 characters. |
ctaConnectText | data-cta-connect-text | Connect Klever | Disconnected CTA label, max 40 characters. |
chrome | data-chrome | off | Flattens the internal card chrome for bare modal embeds. |
tokenChipStyle | data-token-chip-style | pill | Both token controls: pill, flat or minimal. |
In a URL, encode values (# → %23). In
data-* attributes and mount() keys, pass the raw value.
Colours accept #RGB, #RRGGBB, #RRGGBBAA or
rgb()/rgba(); other values are replaced with the default.
Loader-only options
| Attribute | Default | Description |
|---|---|---|
data-target | previous element | CSS selector of the mount node. |
data-manual | false | true stops auto-mounting so you can call mount() yourself. |
data-min-height | 520 | Initial iframe height in px. |
data-max-height | 900 | Ceiling for auto-resize. |
data-max-width | 420 | Iframe max width in px. |
data-base-url | script origin | Widget origin override. Needed only when you self-host the loader — see CSP and script delivery. |
Reserved parameters (no effect today)
theme, compact and network are accepted for
forward-compatibility and do nothing — the surface is dark-first and mainnet-only.
React / SPA mounting
Add data-manual="true" so the loader doesn't auto-mount, then call
mount() yourself. It returns an instance with destroy(), and
you can re-mount on the same node — for example each time a modal opens.
<div id="swopus-widget"></div>
<script src="https://v2.swopus.com/embed.js" data-manual="true"></script>
<script>
const widget = window.SwopusWidget.mount('#swopus-widget', {
projectToken: 'YOUR-TOKEN',
tokenIn: 'KLV',
mode: 'swap',
accent: '#CDFF00',
ctaText: 'Buy now',
});
// later: widget.destroy();
</script> Three details decide whether the React version works on the first try:
- Wait for the loader.
window.SwopusWidgetmay not exist when your effect runs. Optional chaining hides that — you get no widget and no error. - Attach the listener before
mount(), or you miss the earlyloadedevent. - Give any skeleton a timeout failsafe, so a missed event can't leave it covering a working widget.
import { useEffect, useRef, useState } from 'react';
const LOADER_SRC = 'https://v2.swopus.com/embed.js';
/** Resolves once window.SwopusWidget exists, injecting the loader if needed. */
function loadWidgetScript(): Promise<void> {
if (window.SwopusWidget) return Promise.resolve();
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${LOADER_SRC}"]`,
);
const script = existing ?? document.createElement('script');
if (!existing) {
script.src = LOADER_SRC;
script.async = true;
script.dataset.manual = 'true';
document.head.appendChild(script);
}
return new Promise((resolve, reject) => {
script.addEventListener('load', () => resolve(), { once: true });
script.addEventListener('error', () => reject(new Error('loader failed')), { once: true });
});
}
export function BuyWidget() {
const ref = useRef<HTMLDivElement>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
let widget: { destroy(): void } | undefined;
let cancelled = false;
const onWidget = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail?.type === 'loaded') setReady(true);
// Treat as a HINT to refresh, never as proof — see Events.
if (detail?.type === 'swap_confirmed') refreshMyBalances();
};
node.addEventListener('swopus:widget', onWidget); // before mount()
loadWidgetScript()
.then(() => {
if (cancelled) return;
widget = window.SwopusWidget?.mount(node, {
projectToken: 'YOUR-TOKEN',
tokenIn: 'KLV',
mode: 'swap',
accent: '#CDFF00',
ctaText: 'Buy now',
});
})
.catch(() => setReady(true)); // don't leave a skeleton over a dead mount
const failsafe = setTimeout(() => setReady(true), 4000);
return () => {
cancelled = true;
clearTimeout(failsafe);
node.removeEventListener('swopus:widget', onWidget);
widget?.destroy();
};
}, []);
return <div ref={ref} style={{ minHeight: 520, opacity: ready ? 1 : 0.4 }} />;
} Events
The loader re-dispatches widget messages as swopus:widget CustomEvents on
your mount node.
Events are not proof of anything. They are CustomEvents on a node in
your own page, so any script running there — yours, a tag manager's, an injected one —
can dispatch an identical swap_confirmed with any payload. Use them to
refresh your UI. If you grant anything of value — a bonus, an allocation, an
NFT, a role — verify the transaction hash on-chain from your backend first:
that it exists, that it is the expected swap, and that it came from the address you
are crediting.
const node = document.querySelector('#swopus-widget');
node.addEventListener('swopus:widget', (e) => {
if (e.detail.type === 'swap_confirmed') {
refreshMyBalances(); // fine: cosmetic
// grantReward(e.detail.hash) // NOT fine without on-chain verification
}
}); | Type | When | Payload |
|---|---|---|
loaded | widget UI ready inside the iframe | empty — use it to drop your skeleton |
resize | content height changes | height, rawHeight. The loader already applies it; listen only if your own layout must react — a sticky footer, or a modal that measures its body. |
swap_confirmed | a swap settles on-chain | hash, tokenIn, tokenOut, amountIn, amountOut |
swap_rejected | the user declined in their wallet | reason, tokenIn, tokenOut, amountIn |
swap_failed | the swap could not complete — a revert, a build failure, a network error | reason, tokenIn, tokenOut, amountIn, and hash when the transaction reached the chain |
hash is what separates the two failure cases programmatically: present means
the transaction reached the chain and reverted, absent means it never got that far. There
is still no event for an account switch. After a confirmed swap
the widget clears its own inputs, keeps the success state and refreshes the displayed
balance — you don't need to remount it.
Embeds below the fold. Browsers defer booting off-screen iframes, so a
widget deep in the page fires loaded when it approaches the viewport, not
at page load. The loader keeps the handshake alive and re-arms it on visibility, so
events flow within a second or two of scrolling into view.
Direct iframe
For a static page or a quick test, point an iframe straight at the widget URL:
<iframe
src="https://v2.swopus.com/embed/swap?projectToken=YOUR-TOKEN&tokenIn=KLV&mode=swap&accent=%23CDFF00&radius=16"
style="width:100%;max-width:420px;min-height:520px;border:0;border-radius:16px"
title="Buy YOUR-TOKEN on Swopus"
></iframe> Being cross-origin, a raw iframe cannot reach your page's wallet: it quotes, but cannot sign. For real users on your domain use the script loader, which bridges the wallet.
Transaction flow
- User enters an amount; the widget quotes through the Swopus routing engine.
- It shows the expected output and Min received.
- User connects their Klever Wallet and taps the CTA.
- The wallet opens the final confirmation — approve or reject happens there. The widget builds the route and never signs.
-
On success it shows the shortened transaction hash linked to the explorer, clears the
amount, refreshes the balance and fires
swap_confirmed.
If the routing backend is briefly degraded, the widget shows a “no route, try again” state rather than a broken page.
CSP and script delivery
If your site sends a Content-Security-Policy, the widget needs two directives. Nothing
else: the loader creates an iframe and sets element styles through the CSSOM, which CSP
does not govern, and it never injects <style> or markup.
Content-Security-Policy:
script-src 'self' https://v2.swopus.com;
frame-src https://v2.swopus.com;
Fonts, API calls and styles inside the widget are governed by our own headers on
v2.swopus.com, not by yours — you do not need font-src,
connect-src or style-src entries for it.
Versioning, and why there is none
embed.js has no version in its filename and is served
no-store, so every page load gets the current loader. That is deliberate: a
fix reaches every partner at once, without anyone redeploying. The trade-off is that you
cannot pin a build or use Subresource Integrity — an SRI hash would break on the next
update.
If your release process requires a pinned, hash-verified asset, self-host a copy of
embed.js and point it back at us with data-base-url:
<script
src="/vendor/swopus-embed.js"
integrity="sha384-…"
crossorigin="anonymous"
data-base-url="https://v2.swopus.com"
data-target="#swopus-widget"
data-project-token="YOUR-TOKEN"
></script> You then own the update cadence — including picking up fixes. Tell us you are self-hosting so we can warn you before anything changes.
Security
- Signing stays in the wallet. The widget builds the route; the user's Klever Wallet opens the final confirmation. Keys never enter the iframe and the widget takes no custody of funds.
- The wallet bridge is bound to the frame the loader created. It accepts messages only from that iframe's origin and window reference, and replies only to it — another frame on your page cannot reach it.
- Serve the widget from
https://v2.swopus.com. Don't proxy it through a third-party domain unless we have approved it. If you self-host the loader, keepdata-base-urlpointed at us. - Never put wallet addresses, private data or secrets in the widget URL.
- Treat widget events as untrusted input — see Events.
Trading carries risk, and the widget is a live market interface: your users are subject to the same conditions as on the app. See our risk disclaimer and security page.
Troubleshooting
Errors inside the widget stay inside the widget
The iframe is a separate window. Its unhandled rejections never reach your page's error handlers or your monitoring. Widget-internal noise will not pollute your error stream, and you cannot trap widget failures from the host page. If it misbehaves, reproduce with the iframe console open and report it through your partner channel.
A DOM-update timeout in the iframe console
TimeoutError: Transition was aborted because of timeout in DOM update came
from the View Transitions API on deferred boots and was cosmetic. Current bundles disable
view transitions in the embed frame, so seeing it means you are on a cached bundle —
hard-reload, and bust your CDN cache if you self-host the loader.
Nothing renders
Check, in order: the mount node exists when the loader runs (data-target
matches); your CSP allows both directives above; and — for a manual mount — that
window.SwopusWidget existed when you called it.
What's next, and where to ask
On the roadmap: more partner events (quote_requested,
swap_submitted), attribution dashboards keyed on partner,
runtime theming after mount, and a light theme. The parameter contract above is stable —
those additions won't rename anything, so an integration you build today keeps working.
Missing a parameter or a theme knob that isn't exposed? Reach the Swopus team through your partner channel. A working reference integration is always live at blockdao.io.