Kvarn X

KvarnPay — Merchant integration guide

Everything you need to put the KvarnPay widget in your product: one endpoint on your backend, one iframe on your page.

What KvarnPay is

KvarnPay is an embeddable crypto on-ramp built by Kvarn Capital. You drop our widget into your app; your customer picks an amount, verifies their identity, and pays by card — all inside the frame, without leaving your product. We then credit the equivalent crypto to your account in Kvarn's ledger, attributed to the customer who paid it in.

The paying customer is Kvarn's customer: we run the identity check (KYC — the verification a regulated financial business must run before taking someone's money), we hold the customer record, and we own the payment relationship. You receive the crypto. No crypto moves on-chain per payment — balances accumulate in the ledger and are transferred out to you separately.

How the integration works

Four moving parts, and only two of them are yours. You never call our API directly: your backend signs a token, your page hands that token to our widget, and the widget does the rest.

  1. Your backend signs a short-lived token asserting who your customer is — your own reference for them, and nothing else. It is signed with the secret Kvarn gave you.
  2. Your page embeds our widget in an iframe, with that token in the URL fragment — the part of a URL after #, which browsers keep local and never send to any server.
  3. The widget exchanges the token with our API for a KvarnPay session. Your servers never talk to ours; the only thing you produce is the token.
  4. The customer completes the flow inside the widget — amount, identity check, card payment — and we credit the crypto to your ledger account.
How the token travels Your backend signs a ten-minute token and hands it to your page. Your page loads the KvarnPay widget in an iframe with the token in the URL fragment. The widget exchanges the token with the KvarnPay API for a session token. The customer then completes the amount, identity check and card payment inside the widget. Your backend your server — the only place the secret lives 1. a signed token, good for 10 minutes, naming your customer Your page where your customer already is 2. iframe src — the token rides in the URL fragment KvarnPay widget an iframe on your page 3. exchanges the token for a KvarnPay session KvarnPay API verifies the token, opens the session 4. Amount, identity check and card payment then all happen inside the widget.
The token is the whole contract between us. Everything below it is ours to run.

Before you start

Kvarn hands you two values directly when your account is created:

  • Your merchant id — a UUID. It identifies you to us and is not secret.
  • Your JWT secret — the shared key your tokens are signed with. It is secret, and it is the only credential in this integration.

Throughout this guide they appear as YOUR_MERCHANT_ID and YOUR_JWT_SECRET.

Careful: the signing algorithm is HS256, which is symmetric — the same secret both signs and verifies, so anyone who can check a token can also mint one. The secret must live only on your server: never in browser code, never in a mobile app bundle, never committed to a repository. Anyone holding it can open a KvarnPay session as any of your customers.

Step 1

Sign a merchant token

The token is a JWT — a JSON payload with a signature stapled on, so the receiver can tell nobody edited it; its fields are called claims. Your backend mints one when a logged-in customer opens the payment flow, and it asserts exactly one thing: your own reference for that customer.

The claims we accept

Claim Value
alg HS256 — the only algorithm we accept. A token naming anything else is rejected. Strictly this lives in the token's header, not among the payload claims; the algorithm option in the snippet below is what sets it.
iss Your merchant id, exactly as Kvarn gave it to you (a UUID).
aud The literal string kvarn-pay.
sub Your own reference for the customer. It must be stable: the same customer must always get the same value, because it is what we key their record and their credited crypto to.
iat Required. It may not be dated in the future — your clock and ours are compared, with 30 seconds of tolerance.
exp Required, and no further out than iat plus 10 minutes. A longer expiry does not buy a longer token; it is rejected outright.

Any other claim you add is ignored — we deliberately do not accept a name or an email from you. The verified name and email come from the identity check, never from an assertion, so nothing you send about the person can influence a KYC record.

An endpoint your page can call

The natural shape is one authenticated endpoint on your own backend that mints a token for whoever is logged in. Using jsonwebtoken on Node:

import express from 'express';
import jwt from 'jsonwebtoken';

const app = express();

// Both handed to you by Kvarn. Read them from your secret store —
// YOUR_JWT_SECRET must never reach the browser.
const MERCHANT_ID = process.env.YOUR_MERCHANT_ID;
const JWT_SECRET = process.env.YOUR_JWT_SECRET;

app.post('/kvarnpay/token', requireLogin, (req, res) => {
  const merchantToken = jwt.sign({}, JWT_SECRET, {
    algorithm: 'HS256',
    issuer: MERCHANT_ID,
    audience: 'kvarn-pay',
    subject: req.user.id, // your own reference for this customer
    expiresIn: '10m',
  });

  res.json({ merchantToken });
});

The payload is empty on purpose: issuer, audience, subject and expiresIn are what produce iss, aud, sub, iat and exp. Guard the endpoint with your own session check — whoever can call it can open a KvarnPay session as the customer it names.

Careful: every rejection answers with one generic 401 invalid merchant token, on purpose. Naming the failing step would turn the endpoint into an oracle for guessing merchant ids, so the response body will never tell you which claim was wrong. Debug by re-checking your signing inputs — secret, merchant id, audience, clock — not by reading the error.

Step 2

Embed the widget

The widget is a plain iframe. Fetch a fresh token from your own backend at the moment the customer opens the flow, then set it as the frame's source:

<iframe
  id="kvarnpay"
  title="KvarnPay"
  allow="camera; microphone; payment"
  style="width: min(33rem, 100%); height: 42rem; border: 0"
></iframe>

<button onclick="openKvarnPay()">Buy crypto</button>

<script>
  async function openKvarnPay() {
    const res = await fetch('/kvarnpay/token', { method: 'POST' });
    const { merchantToken } = await res.json();

    document.getElementById('kvarnpay').src =
      'https://staging.pay.kvarnx.com/#token=' +
      encodeURIComponent(merchantToken);
  }
</script>

Mint the token on demand, not when the page renders. A token lives ten minutes. If you sign one into the HTML of a page a customer leaves open, it will have expired by the time they click Pay, and the widget will fail to open a session.

Why the fragment, and not a query parameter

The token goes after the #. A fragment is never sent to any server — not to ours, not to a CDN in front of it — so the token stays out of every request log on the way. A query parameter would be written to all of them. The widget reads the token as its first act and immediately rewrites its own address without it, so a spent token is not left sitting in the frame's history entry either.

Sizing

width: min(33rem, 100%) and height: 42rem are the dimensions the widget's layout is designed against: wide enough for the card payment and document capture steps, and tall enough that the amount screen fits without scrolling. Narrower or shorter works — the widget is responsive — but it will scroll inside your frame.

Careful: the allow attribute is required, and its contents are not decorative. camera and microphone are what the identity check needs to capture the customer's document; payment is what lets Apple Pay and Google Pay appear in the card step. An iframe does not inherit these permissions from your page — without the attribute, those steps simply fail inside your product, and they fail late, after the customer has already committed to an amount.

What your customer sees

Once the frame opens, the flow is ours. It is worth knowing its shape, because your support team will hear about it.

  • An amount, and an asset. They type a figure in euros and pick which crypto to buy. A live quote sits under the amount: what it will buy at the current price, and the fee. The fee is always on screen, never behind a disclosure. A consent line naming the amount and your business gates the Pay button.
  • An identity check, once. On a customer's first payment they are taken through KYC inside the frame. It is a one-time step: a returning customer who has been approved goes straight from the amount to the card.
  • A card payment on a hosted payment page — a form served from our payment provider's own domain, loaded inside the widget. Card details never touch your code, and never touch ours either. 3-D Secure runs there when the card requires it.
  • A receipt. When the crypto has been bought and credited, the widget shows what actually happened: the crypto bought, the money paid, the fee, the price and a reference the customer can quote to support.

The price on the receipt is the price the crypto was really bought at, at the moment the payment was captured. The quote shown while the customer types is indicative and moves with the market, so the two figures are close but need not be identical.

What it means for you

The crypto is credited to your account in Kvarn's ledger, attributed to the customer reference you asserted in sub. That attribution is why sub has to be stable: it is how a balance is traced back to the person who brought it in. Nothing moves on-chain per payment; accumulated balances are transferred out to you separately.

Which assets your customers can buy is configured per merchant when your account is set up — the widget offers exactly the assets enabled for you, and nothing else. Talk to Kvarn to change the list.

Reference

Environments

Environment Widget origin
Pilot / staging https://staging.pay.kvarnx.com
Production To be announced.

Staging runs against sandbox card processing and sandbox identity checks: no real money and no real documents. Your staging merchant id and secret are not your production ones.

Token checklist

alg
HS256, always. Nothing else is accepted.
iss
Your merchant id (a UUID), exactly as issued.
aud
The literal string kvarn-pay.
sub
Your reference for the customer. Stable, one per customer.
iat
Present, and not in the future. 30 seconds of clock tolerance.
exp
Present, and at most iat + 10 minutes.
everything else
Ignored. Do not send a name or an email — we will not use them.

Troubleshooting

Symptom Likely cause
The widget opened fine, then a later attempt is rejected The token is older than 10 minutes. Sign a fresh one each time the customer opens the flow.
Every token is rejected, and the code looks right Your server's clock is off by more than 30 seconds. Check that it is synchronised.
A new integration is rejected from the first call aud is not exactly kvarn-pay, or iss is not your merchant id — a display name or a staging id used against production will both fail.
Rejected after rotating credentials The secret and the merchant id no longer match each other. They are a pair; a token is verified with the secret belonging to the id in iss.
The widget says it was opened without a session No token reached it — usually a token put in the query string instead of after the #, or an iframe whose src was set before the token arrived.
The identity step cannot open the camera, or the wallet buttons are missing The iframe's allow attribute is missing or incomplete. It must carry camera; microphone; payment.