Skip to content

Events on your server ​

The widget shows your customer what happened, not you. If your own systems have to act on a payment, or on a payout to you, they should act on what our servers say: whenever a payment or a payout reaches an outcome we post a signed JSON event to a URL you give us. This is optional — skip this page if the customer's own receipt and the portal are all you need.

The events ​

EventMeans
payment.capturedThe card payment succeeded. The crypto purchase is under way but not finished.
payment.creditedThe crypto has been bought and credited to your balance, attributed to the paying customer. Carries the exact quantity.
payment.failedThe payment failed. Includes a short reason where our payment provider gave one.
payment.refundedThe payment was refunded or charged back, from whatever state it was in.
payout.completedA payout to you arrived: what arrived, what the network took and the transaction hash.
payout.failedThe payout did not transfer funds and its booking was reversed. Also sent when Kvarn cancelled a payout before anything was sent.

payment.credited normally follows payment.captured by a few seconds: the money arrives first, then the crypto is bought at the live market rate. Handle payment.captured if you want the fast signal that the money is safe; payment.credited is the one that settles your books. Handling payment.credited alone is a complete integration for payments; add payout.completed to learn when the crypto reached you without polling GET /v1/payouts.

Getting set up ​

Register the https URL you want events posted to on the Settings page of your portal (see Your portal). The first registration issues you a signing secret, shown once and not readable afterwards, only replaced. Store it like YOUR_JWT_SECRET (see Before you start): server-side, never in a repository or anything the browser downloads. It appears as YOUR_WEBHOOK_SECRET below.

Staging and production endpoints are registered separately, each with its own secret, so a staging event posted to your production receiver fails verification instead of writing a test payment into your live books. Changing the URL keeps the secret; rotating the secret, from the same page, shows the new one once and signs every delivery with it from the next event on.

What arrives ​

A POST with a JSON body. Every event has the same outer shape, and a data object whose fields depend on the type:

json
{
  "id": "9f1c3d7e-0b52-4a11-9c8e-6d2f4b7a1c30",
  "type": "payment.credited",
  "createdAt": "2026-08-27T10:00:12.418Z",
  "data": {
    "paymentId": "3a2b91c4-5e77-4a0d-8f16-2c9d0e4b7351",
    "merchantId": "YOUR_MERCHANT_ID",
    "customerId": "c07d5a18-3f64-4e29-b8a1-92d6f0c15b47",
    "externalRef": "user_8421",
    "assetSymbol": "BTC",
    "amountBase": "139855",
    "amountDecimal": "0.00139855",
    "fiatAmountMinor": "10000",
    "fiatCurrency": "EUR",
    "feeMinor": "200",
    "merchantFeeMinor": "150",
    "creditedAt": "2026-08-27T10:00:12.204Z"
  }
}

id identifies the event and is the same on every delivery attempt — use it as your idempotency key. type is one of the six above. createdAt is when we emitted the event, not when this delivery was attempted.

In every payment event's dataValue
paymentIdOur identifier for the payment.
merchantIdYour merchant id.
customerIdOur identifier for the customer.
externalRefThe sub claim from your token. Key on this. null only for a customer record with no reference.

payment.captured, payment.failed and payment.refunded add what was paid:

FieldValue
amountMinorWhat the customer paid, in minor units (10000 is €100.00).
currencyISO code, e.g. EUR.
statusThe payment's status when the event was emitted.
failureReasonpayment.failed only; null where no reason was given. For your logs, not for customers.

payment.credited instead carries the purchase itself:

FieldValue
assetSymbolWhat was bought, e.g. BTC.
amountBaseThe crypto credited, in the asset's smallest unit (satoshis for BTC).
amountDecimalThe same quantity as a decimal.
fiatAmountMinorThe gross amount the customer paid, in minor units.
fiatCurrencyISO code of that amount.
feeMinorOur fee, in minor units, already deducted — amountBase was bought with what was left after both fees.
merchantFeeMinorYour own fee on this payment, in minor units — the merchantFeeBps you signed, applied to fiatAmountMinor. 0 when you charged none. Present on events emitted from September 2026 onwards: an event's body is frozen when we emit it, so a redelivery of an older event resends the bytes we stored then.
creditedAtWhen the credit was written, ISO 8601.

Payout events ​

payout.completed and payout.failed carry the payout itself: data holds exactly the fields a payout has on GET /v1/payouts/{id}, with the payout's id named payoutId and your merchantId added.

json
{
  "id": "2d6b0e4a-7c13-4f58-9a21-8e0f3b5d6c47",
  "type": "payout.completed",
  "createdAt": "2026-10-05T03:41:28.102Z",
  "data": {
    "payoutId": "c41d2a8f-6b0e-4d93-8f27-1a5e9c3b7d60",
    "merchantId": "YOUR_MERCHANT_ID",
    "assetSymbol": "ETH",
    "network": "ethereum",
    "progress": "completed",
    "trigger": "scheduled",
    "amountBase": "250000000000000000",
    "amountDecimal": "0.250000000000000000",
    "creditCount": 3,
    "fiatValuationMinor": "61250",
    "fiatCurrency": "EUR",
    "fiatValuationDecimal": "612.50",
    "destinationAddress": "0x9f2c…",
    "sentAmountDecimal": "0.249800000000000000",
    "feeAmountDecimal": "0.000200000000000000",
    "txHash": "0x7a1e…",
    "createdAt": "2026-10-05T03:00:00.418Z",
    "sentAt": "2026-10-05T03:00:09.930Z",
    "completedAt": "2026-10-05T03:41:27.930Z",
    "endedAt": null
  }
}
FieldValue
payoutIdOur identifier for the payout — GET /v1/payouts/{id} reads its record, GET /v1/payments?payoutId=… pages the payments it settled.
merchantIdYour merchant id.
assetSymbol, networkWhat was paid out, and on which network.
progresscompleted on payout.completed, failed on payout.failed.
triggerscheduled or on_demand.
amountBase, amountDecimalWhat was booked for the payout: the credits it took, whole.
creditCountHow many payments it settled.
fiatValuationMinor, fiatCurrency, fiatValuationDecimalWhat that was worth when it was booked.
destinationAddressThe approved address it went to.
sentAmountDecimal, feeAmountDecimalWhat arrived and what the network took, once completed; null before and on a failure.
txHashThe transaction hash once completed; null otherwise.
createdAt, sentAt, completedAt, endedAtWhen it was booked, handed over, arrived, or failed — ISO 8601, null where it did not happen.

Which payments the payout settled is not in the event — a payout can carry thousands; page them by payoutId as the table says.

On payout.failed nothing was transferred and the payout's booking was reversed. Do not assume the payments are payable again: a payment refunded meanwhile is not, and a later payout may already have taken the others before this event reaches you. Read your balance and each payment's settlement for where the crypto is now.

Every amount is a string of digits or a decimal string, never a JSON number. JSON numbers are floats, and a large enough integer loses its last digits. Parse them as integers or decimals.

Verifying the signature ​

Every delivery carries a header proving it came from us and was not modified:

text
kvarnpay-signature: t=<unix-seconds>,v1=<hex>

v1 = HMAC-SHA256(YOUR_WEBHOOK_SECRET, "<t>." + <raw request body>)

To check it:

  1. Split the header on , to read t and v1.
  2. Recompute the HMAC over t, a literal ., and the raw request body, with your webhook secret as the key.
  3. Compare to v1 with a timing-safe comparison (crypto.timingSafeEqual on Node, hmac.compare_digest in Python).
  4. Reject anything where t is more than 300 seconds from your own clock. t is inside the signature, so a captured delivery cannot be re-stamped and replayed later.

v1 is a version tag. If the scheme changes, a second value is added alongside it in the same header, so you can accept both during the changeover.

Careful

Hash the raw request bytes, not re-serialized JSON — key order and whitespace are part of what was signed. In Express, use express.raw on this route, not express.json.

js
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

// Handed to you when your endpoint is registered. Same handling as
// YOUR_JWT_SECRET — server-side only.
const WEBHOOK_SECRET = process.env.YOUR_WEBHOOK_SECRET;

app.post(
  '/kvarnpay/events',
  // Raw, not parsed: the signature covers the bytes as sent.
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const parts = Object.fromEntries(
      (req.get('kvarnpay-signature') || '')
        .split(',')
        .map((part) => part.split('=')),
    );

    const raw = req.body.toString('utf8');
    const t = Number(parts.t);

    if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
      return res.sendStatus(400); // missing or stale timestamp
    }

    const expected = createHmac('sha256', WEBHOOK_SECRET)
      .update(parts.t + '.' + raw)
      .digest('hex');
    const received = Buffer.from(parts.v1 || '', 'hex');

    if (
      received.length !== 32 ||
      !timingSafeEqual(received, Buffer.from(expected, 'hex'))
    ) {
      return res.sendStatus(401);
    }

    res.sendStatus(200); // acknowledge first…
    void handleEvent(JSON.parse(raw)); // …then do your own work
  },
);

What your endpoint has to tolerate ​

Careful

Answer 2xx within 5 seconds. After that we count the delivery as failed. Acknowledge first, then do your work off the request. Redirects are not followed and count as failures.

The same event can arrive twice. Delivery is at-least-once. Record the event id and make a repeat a no-op.

Events are not ordered. payment.refunded can arrive before payment.captured, and a refund that overtook the purchase is still followed by payment.credited — the crypto was bought, and the refund reverses it. A payout.completed can even arrive before the payment.credited of a payment it settled: the payout reads our ledger, not our outbox. Treat each event as a statement about the payment or the payout, not a step in a sequence.

We retry for about half a day. Failed deliveries are retried on a widening interval, from seconds to an hour, for roughly 8–12 hours. After that we stop and flag it on our side; the event is not replayed later, so a receiver that was down longer needs the gap filled by hand.

Powered by Kvarn X

© 2026 Kvarn Capital Ltd