Skip to content

Pull your data into your own systems ​

Everything your portal shows about your money — payments, your customers by the reference you gave them, balances, and the events we posted to you — your own servers can read too, with an API key. Read-only: nothing here changes anything. Optional, like the events: the portal is enough for many merchants.

Getting a key ​

An owner creates a key on the portal's API keys page, gives it a label (where it will live), and sees the key exactly once. Copy it into your secret store before closing the dialog: we keep only a hash of it and cannot show it again. A lost key is revoked and replaced.

  • A key looks like kpk_test_… on staging and kpk_live_… in production, followed by 64 hex characters. The tag says which environment it opens; a key of one is refused by the other before anything is looked up.
  • Up to ten live keys at a time. Rotation is: create the new one, switch your systems over, revoke the old one. Both work in the meantime, so there is no downtime — and it needs a free slot under the ten.
  • A key belongs to your company, not to the person who created it: it keeps working when they leave. When someone who could have seen a key leaves, revoke the keys they could hold.
  • Revocation is immediate: every request after it fails with 401 and "code": "api_key_revoked". We may also revoke a key if we believe it has leaked; the list shows who revoked what, and when.

Careful

The key is for your servers only — never in a browser, a mobile app, or a repository. The API answers no CORS headers, so a call from a web page fails on purpose. HTTPS only.

Making a request ​

Send the key as a bearer token. You never send your merchant id: the key names it, and every answer is scoped to your merchant. The first call to make is the one that says whose key it is:

sh
curl https://api.staging.pay.kvarnx.com/v1/merchant \
  -H "Authorization: Bearer kpk_test_…"
json
{
  "merchantId": "b6a1…",
  "name": "Your company",
  "status": "active",
  "key": { "id": "9f2c…", "label": "ERP sync" }
}

What you can read ​

RouteAnswers
GET /v1/merchantYour merchant and the key you called with.
GET /v1/paymentsYour payments, newest first, with both fees and the crypto each one bought at the booked rate, and settlement: whether the crypto has been paid out to you (unsettled, settling, settled, with the payout). Filters: customerId, customerRef (the sub you gave the customer, matched exactly), payoutId (the payments a payout carried), status, settlement (unsettled as a filter means captured payments whose crypto is still held for you — a payment that bought nothing is not listed), from, to (ISO 8601; from inclusive, to exclusive).
GET /v1/payments/{id}One payment with the ledger entries it produced on your account and the events we posted for it.
GET /v1/customersYour customers by reference, with whether they may pay and what they paid and bought in total. Filters: kycStatus, customerRef. You never see who they are: the identity check is ours.
GET /v1/customers/{id}One customer's books: per asset what was bought, held and reversed; per currency what was paid, refunded, and what each fee took.
GET /v1/balancesWhat you hold, per asset, attributed to the customer who brought each amount in, plus card money not yet turned into crypto.
GET /v1/payoutsYour payouts, newest first: the amount, the address, what arrived and what the network took, the transaction hash, and their progress (pending, sent, completed, failed). Filter: progress.
GET /v1/payouts/{id}One payout with its payments, complete; a payout with many is paged through GET /v1/payments?payoutId=….
GET /v1/webhook-eventsEvery event we posted to your server and how the delivery went. Filters: paymentId, payoutId, status, type.

The fields are exactly what your portal shows. Fiat is a string of minor units ("amountMinor": "10000" is 100.00 EUR) with a decimal beside it; crypto is a string of base units with its decimal beside it; timestamps are ISO 8601 in UTC. Amounts are strings on purpose — parse them as decimals, never as floating point.

Following a payout: an example ​

The two payout routes, as an integration reads them. Your completed payouts, newest first:

sh
curl "https://api.staging.pay.kvarnx.com/v1/payouts?progress=completed&limit=1" \
  -H "Authorization: Bearer kpk_test_…"
json
{
  "items": [
    {
      "id": "c41d…",
      "assetSymbol": "BTC",
      "network": "bitcoin",
      "progress": "completed",
      "trigger": "scheduled",
      "amountBase": "123456",
      "amountDecimal": "0.00123456",
      "creditCount": 2,
      "fiatValuationMinor": "10000",
      "fiatCurrency": "EUR",
      "fiatValuationDecimal": "100.00",
      "destinationAddress": "bc1q…",
      "sentAmountDecimal": "0.00123000",
      "feeAmountDecimal": "0.00000456",
      "txHash": "4a5e…",
      "createdAt": "2026-10-05T03:00:00.412Z",
      "sentAt": "2026-10-05T03:00:09.108Z",
      "completedAt": "2026-10-05T03:41:27.930Z",
      "endedAt": null
    }
  ],
  "nextCursor": "MXwy…"
}

amountBase is what left your balance, in base units, with amountDecimal beside it; fiatValuationMinor is its value in your currency at the time, creditCount how many payments it settled, and trigger whether the schedule, Pay out now or Kvarn started it. The network fee comes out of the amount, so sentAmountDecimal plus feeAmountDecimal normally adds up to amountDecimal — both are what the network reported, recorded as they came. Before a payout is sent, sentAmountDecimal, feeAmountDecimal, txHash, sentAt and completedAt are null, and they fill in as it goes; endedAt is set only on a failed one. The progress words are the portal's.

One payout, with the payments it settled:

sh
curl https://api.staging.pay.kvarnx.com/v1/payouts/c41d… \
  -H "Authorization: Bearer kpk_test_…"
json
{
  "payout": {
    "id": "c41d…",
    "assetSymbol": "BTC",
    "network": "bitcoin",
    "progress": "completed",
    "trigger": "scheduled",
    "amountBase": "123456",
    "amountDecimal": "0.00123456",
    "creditCount": 2,
    "fiatValuationMinor": "10000",
    "fiatCurrency": "EUR",
    "fiatValuationDecimal": "100.00",
    "destinationAddress": "bc1q…",
    "sentAmountDecimal": "0.00123000",
    "feeAmountDecimal": "0.00000456",
    "txHash": "4a5e…",
    "createdAt": "2026-10-05T03:00:00.412Z",
    "sentAt": "2026-10-05T03:00:09.108Z",
    "completedAt": "2026-10-05T03:41:27.930Z",
    "endedAt": null
  },
  "payments": [
    {
      "paymentId": "7e90…",
      "customerId": "a2f3…",
      "customerExternalRef": "user-1042",
      "amountBase": "61728",
      "amountDecimal": "0.00061728"
    },
    {
      "paymentId": "9b1c…",
      "customerId": "d5e6…",
      "customerExternalRef": "user-2077",
      "amountBase": "61728",
      "amountDecimal": "0.00061728"
    }
  ],
  "asOf": "2026-10-06T08:12:44.001Z"
}

payments are the credits the payout carried, each with the customer's reference you gave us and the crypto that payment bought; they add up to the payout's amount. The array is the complete set, however many there are. For a payout with many credits, page them instead: GET /v1/payments?payoutId=c41d… answers the same payments as ordinary payment records, newest first, with the cursor every list has, and creditCount on the payout says how many to expect. Each of them carries "settlement": { "status": "settled", "payoutId": "c41d…", "settledAt": "2026-10-05T03:41:27.930Z" }. A payout that failed still names the payments it carried; their settlement then says where the crypto is now. asOf is when the record was read.

Paging through a list ​

A list answers { "items": [...], "nextCursor": "…" }, newest first, up to limit items (default 50, at most 200). Pass nextCursor back as cursor to get the next page; it is null on the last one. The cursor is opaque: keep it as it is, and never build one yourself.

js
async function* payments(apiKey) {
  let cursor = null;
  do {
    const url = new URL('https://api.staging.pay.kvarnx.com/v1/payments');
    url.searchParams.set('limit', '200');
    if (cursor) url.searchParams.set('cursor', cursor);

    const res = await fetch(url, {
      headers: { authorization: `Bearer ${apiKey}` },
    });
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);

    const page = await res.json();
    yield* page.items;
    cursor = page.nextCursor;
  } while (cursor);
}

Careful

A cursor is a position in a newest-first list, not a snapshot and not a change feed. A payment that lands while you are paging is newer than your cursor, so this walk never reaches it: start a fresh walk to see it. A payment whose status changed after you read it is not replayed. Act on status changes through the events, and re-read what you reconcile — a customer's KYC state, for one, changes without an event.

Limits and errors ​

StatusMeans
400A filter you sent is unknown or malformed, or the cursor is not one we issued. The message names the field, never the value.
401invalid api key: missing, malformed, for the other environment, or unknown. With "code": "api_key_revoked": the key was revoked.
404No such payment, customer or payout of yours. Another merchant's id is the same 404 as an unknown one.
422"code": "balance_result_too_large": your balances have more than ten thousand customer attributions and we will not answer a partial total. Ask us for a report.
429Too many requests: 600 a minute across all your keys, two at a time, and a ceiling shared by everyone. Wait the Retry-After seconds and try again; page sequentially rather than in parallel.
5xxOurs. Retry with a backoff.

Every answer carries Cache-Control: no-store. This is version 1 of the API: fields may be added; none will be removed or renamed under /v1.

Powered by Kvarn X

© 2026 Kvarn Capital Ltd