SWOPUS SWOPUS v2
← Docs

Swap widget integration

Embed a live swap on your own site so users can buy or sell your project token without leaving your page. The widget runs the full Swopus routing engine, and signing always happens in the user's own Klever Wallet — it never touches keys or funds.

Updated 15 August 2026

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 buy mode the output is locked to your token; swap mode 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 slippage you pass (default 0.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. bg is the deepest, surface the 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/surface the widget derives dark ink automatically — the same contrast logic the CTA uses for accent. Pass text only 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. fontUrl loads the stylesheet, font selects 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 resize pair 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

ParamLoader attributeDefaultDescription
projectTokendata-project-tokenrequiredYour token. Scopes every trade to routes that include it. Aliases: assetId, project_token.
tokenOutdata-token-outprojectTokenInitial 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.
tokenIndata-token-inKLVDefault token the user pays with. Alias: from.
modedata-modebuybuy locks the output to your token; swap unlocks the flip.
routerdata-routerbest routeoff forces the single direct pool instead of best-execution routing.
slippagedata-slippage0.5Tolerance in percent. Fixed for every user of your embed — see What it does not do.
autoConnectdata-auto-connectonoff stops the widget asking the wallet to connect on open.
defaultAmountdata-default-amountPre-fills the pay amount. Alias: amount.
partnerdata-partnerAttribution key, forwarded for the upcoming partner dashboards.

Branding

ParamLoader attributeExampleDescription
accentdata-accent#CDFF00CTA and active-state colour. Button ink auto-picks dark or light for contrast.
flipIconColordata-flip-icon-color#CDFF00Flip-icon tint. Defaults to accent.
bgdata-bg#0A0A0APage background — the deepest layer.
surfacedata-surface#1A1A1ACard and input surface — the raised layer.
borderdata-border#2A2A2AInternal border colour.
borderWidthdata-border-width2Outline weight in px, up to 6. Applies to the card and both token chips.
radiusdata-radius16Corner radius in px, up to 40. 0 squares the whole widget.
textdata-text#0A0A0APrimary ink. Needed only to override the automatic choice — the example is dark ink for a light palette.
textMuteddata-text-muted#5A6780Secondary ink for labels and sublines.
fontdata-fontArchivo, system-ui, sans-serifFont stack inside the widget. Pair with fontUrl.
fontUrldata-font-urlGoogle Fonts URLLoads your brand face. Accepted only over https, from host fonts.googleapis.com exactly, path starting /css; anything else is dropped.
ctaTextdata-cta-textBuy nowConnected CTA label, max 40 characters.
ctaConnectTextdata-cta-connect-textConnect KleverDisconnected CTA label, max 40 characters.
chromedata-chromeoffFlattens the internal card chrome for bare modal embeds.
tokenChipStyledata-token-chip-stylepillBoth 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

AttributeDefaultDescription
data-targetprevious elementCSS selector of the mount node.
data-manualfalsetrue stops auto-mounting so you can call mount() yourself.
data-min-height520Initial iframe height in px.
data-max-height900Ceiling for auto-resize.
data-max-width420Iframe max width in px.
data-base-urlscript originWidget 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:

  1. Wait for the loader. window.SwopusWidget may not exist when your effect runs. Optional chaining hides that — you get no widget and no error.
  2. Attach the listener before mount(), or you miss the early loaded event.
  3. 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
  }
});
TypeWhenPayload
loadedwidget UI ready inside the iframeempty — use it to drop your skeleton
resizecontent height changesheight, 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_confirmeda swap settles on-chainhash, tokenIn, tokenOut, amountIn, amountOut
swap_rejectedthe user declined in their walletreason, tokenIn, tokenOut, amountIn
swap_failedthe swap could not complete — a revert, a build failure, a network errorreason, 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

  1. User enters an amount; the widget quotes through the Swopus routing engine.
  2. It shows the expected output and Min received.
  3. User connects their Klever Wallet and taps the CTA.
  4. The wallet opens the final confirmation — approve or reject happens there. The widget builds the route and never signs.
  5. 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, keep data-base-url pointed 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.