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
| Event | Means |
|---|---|
payment.captured | The card payment succeeded. The crypto purchase is under way but not finished. |
payment.credited | The crypto has been bought and credited to your balance, attributed to the paying customer. Carries the exact quantity. |
payment.failed | The payment failed. Includes a short reason where our payment provider gave one. |
payment.refunded | The payment was refunded or charged back, from whatever state it was in. |
payout.completed | A payout to you arrived: what arrived, what the network took and the transaction hash. |
payout.failed | The 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 data | Value |
|---|---|
paymentId | Our identifier for the payment. |
merchantId | Your merchant id. |
customerId | Our identifier for the customer. |
externalRef | The 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:
| Field | Value |
|---|---|
amountMinor | What the customer paid, in minor units (10000 is €100.00). |
currency | ISO code, e.g. EUR. |
status | The payment's status when the event was emitted. |
failureReason | payment.failed only; null where no reason was given. For your logs, not for customers. |
payment.credited instead carries the purchase itself:
| Field | Value |
|---|---|
assetSymbol | What was bought, e.g. BTC. |
amountBase | The crypto credited, in the asset's smallest unit (satoshis for BTC). |
amountDecimal | The same quantity as a decimal. |
fiatAmountMinor | The gross amount the customer paid, in minor units. |
fiatCurrency | ISO code of that amount. |
feeMinor | Our fee, in minor units, already deducted — amountBase was bought with what was left after both fees. |
merchantFeeMinor | Your 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. |
creditedAt | When 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
}
}| Field | Value |
|---|---|
payoutId | Our identifier for the payout — GET /v1/payouts/{id} reads its record, GET /v1/payments?payoutId=… pages the payments it settled. |
merchantId | Your merchant id. |
assetSymbol, network | What was paid out, and on which network. |
progress | completed on payout.completed, failed on payout.failed. |
trigger | scheduled or on_demand. |
amountBase, amountDecimal | What was booked for the payout: the credits it took, whole. |
creditCount | How many payments it settled. |
fiatValuationMinor, fiatCurrency, fiatValuationDecimal | What that was worth when it was booked. |
destinationAddress | The approved address it went to. |
sentAmountDecimal, feeAmountDecimal | What arrived and what the network took, once completed; null before and on a failure. |
txHash | The transaction hash once completed; null otherwise. |
createdAt, sentAt, completedAt, endedAt | When 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:
- Split the header on
,to readtandv1. - Recompute the HMAC over
t, a literal., and the raw request body, with your webhook secret as the key. - Compare to
v1with a timing-safe comparison (crypto.timingSafeEqualon Node,hmac.compare_digestin Python). - Reject anything where
tis more than 300 seconds from your own clock.tis 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.