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.
- 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.
-
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. - 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.
- The customer completes the flow inside the widget — amount, identity check, card payment — and we credit the crypto to your ledger account.
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.
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.
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.
|