NorelioDeveloper documentation

Top-ups

Sell credits: a product with packages and a free amount, three ways to start the payment (widget attributes, direct checkout, server API), the credits in the webhook, a handler in Node.js and Python, refunds.

A top-up is the third kind of product, next to a one-off purchase and a subscription: the buyer picks one of the packages you set ("100 credits for 10.00") or enters an amount of their own, pays it, and your server adds the credits to the buyer's account when the order.paid webhook arrives. Norelio keeps no balance: it tells you how many credits were bought and leaves the ledger to you. The price is without VAT; VAT is added on top for EU buyers, as for every product.

The product

A top-up is created in the cabinet catalog like any product, with the access type "Top-up" and the delivery method "partner delivers (webhook)" — the only delivery method a top-up accepts, since the credits live on your side. Without a webhook address saved in the cabinet the product is not for sale, and the card says so.

  1. In the cabinet catalog, add a product (or edit one) and choose the access type "Top-up". The price field disappears: the catalog and the button show "from" the smallest amount you offer.
  2. Name the unit the buyer will see next to the number — "credits", "tokens" — up to 24 characters.
  3. Fill in up to 6 packages, each an amount in the product's currency and the number of credits it buys. The package number is its row in the card; "data-package" and "packageId" name that row.
  4. Optionally switch on a free amount: the minimum, the maximum and the number of credits per 1.00 of the product's currency. At least one package or the free amount is required.
  5. Leave the delivery method on "partner delivers (webhook)" and make sure the webhook address is saved under API and webhooks → Webhooks.

Amounts are between 0.50 and 1000.00 in the product's currency, for packages and for the free amount alike. Credits for a free amount are floor(amount × rate ÷ 100), where amount is in minor units: with 10 credits per 1.00, an amount of 12.50 buys 125 credits. The server always takes the package or checks the amount against the product in the database and computes the credits itself; a number of credits sent by a page or a server is never accepted anywhere.

A top-up can be bought only with your account reference (the client reference): without it Norelio would not know whom you should credit. Every entry point requires it — the widget panel, direct checkout and the server API — and refuses without it, creating no order; on the payment page the buyer sees "Open this payment from the seller's app to top up your account.". For the same reason a top-up is never shown on the Norelio marketplace or on the partner's storefront and has no direct payment link.

Three ways to start the payment

There are three ways to start a top-up payment, all of them with the client reference:

The widget

The widget with the Norelio panel: the plain button and the card both open the panel, where the buyer picks a package or types an amount and sees "N credits for X" before the pay button. Two optional attributes on the widget element choose for the buyer in advance: data-package with the package number, or data-amount with the amount in minor units. They are read at the click, like the client reference. See Payment widget and checkout panel.

Package data-package
<div data-lis-widget="wgt_1a2b3c" data-client-reference="acc_10294" data-package="2"></div>
Amount data-amount, minor units
<div data-lis-widget="wgt_1a2b3c" data-client-reference="acc_10294" data-amount="1250"></div>

Direct checkout

With direct checkout enabled (see the widget section), a click with data-package or data-amount set goes straight to the payment provider with that package or amount; with neither set, the click opens a selection panel — packages and the amount, no terms and no checkbox, since the consent is yours — and the payment starts from your page once the buyer confirms. The server checks the package or the amount against the product either way.

From your server

From your server: POST /api/v1/checkouts with productId, clientReference and exactly one of packageId or amount. Both, neither, a package number not in the card or an amount outside the range answer 400 invalid_request without an order; any of the two fields on a product that is not a top-up is the same 400. The 201 response carries credits and creditsUnit — the number Norelio will report in the webhook — next to the usual fields. Everything else about the endpoint is in Create a payment from your server.

Request body with a package
{
  "productId": "prod_7t0p9u",
  "clientReference": "acc_10294",
  "customerEmail": "buyer@example.com",
  "packageId": 2
}
Request body with an amount
{
  "productId": "prod_7t0p9u",
  "clientReference": "acc_10294",
  "customerEmail": "buyer@example.com",
  "amount": 1250
}
Example response
{
  "id": "ord_t0p4u9",
  "url": "https://pay.lis-system.eu/api/public/checkout/continue?order=ord_t0p4u9&key=k4Xw1QmR9zL2nT8vB0cY7pH5sJ3eF6gD",
  "expiresAt": "2026-03-14T10:26:53.000Z",
  "clientReference": "acc_10294",
  "warnings": [],
  "credits": 100,
  "creditsUnit": "credits"
}
Both fields, neither, an unknown package or an amount outside the range
{
  "error": {
    "code": "invalid_request",
    "message": "A top-up product takes exactly one of packageId (the package number from the product card, 1-6) or amount (minor units within the product's range); other products take neither."
  }
}

The webhook

The order.paid notification of a top-up carries credits (integer) and creditsUnit (string); on every other order and on subscription events both are null. The fields were added without a version change. The amount is the amount the buyer paid, without VAT, as always. The fields are described in Webhooks.

order.paid for a top-up
{
  "version": 1,
  "event": "order.paid",
  "test": false,
  "productId": "prod_7t0p9u",
  "customerEmail": "buyer@example.com",
  "paymentId": "ord_t0p4u9",
  "amount": 1000,
  "currency": "EUR",
  "taxAmount": 0,
  "occurredAt": "2026-03-14T09:26:53.000Z",
  "accessValidUntil": null,
  "clientReference": "acc_10294",
  "credits": 100,
  "creditsUnit": "credits",
  "refundedAmount": null,
  "eventId": "cm8f4m7r20003cd5e1v8u3t0s"
}

A handler that credits the account

A function that credits the account once per event, in Next.js, Node.js and Python. The mark of the event — its eventId under a unique key in your own database — and the change of credits are written in ONE database transaction: a repeated delivery hits the key and credits nothing, and a failure between the two writes rolls both back, so our retry credits again. A mark written without the credits would make the retry skip the event for good. Call the function from your webhook route after the signature is verified and test notifications are skipped: the route of the Quickstart (step 5) does both. The route: Quickstart, step 5.

lib/norelio-events.ts
import { Pool } from "pg";

// Your database (PostgreSQL here) remembers processed events in this table:
// CREATE TABLE norelio_events (
//   event_id TEXT PRIMARY KEY
// );
const pool = new Pool();

type NorelioWebhookPayload = {
  eventId: string;
  event: string;
  amount: number;
  clientReference: string | null;
  credits: number | null;
  refundedAmount: number | null;
};

function creditsChange(payload: NorelioWebhookPayload, credits: number): number {
  if (payload.event === "order.paid") return credits; // payment confirmed: add the credits
  if (payload.event === "order.refunded_full" || payload.event === "order.refunded_partial") {
    // refund, partial or full: remove the share this refund returned, rounding down;
    // refundedAmount is this refund's amount, in the same units as amount
    return -Math.floor((credits * (payload.refundedAmount ?? 0)) / payload.amount);
  }
  return 0;
}

export async function handleNorelioEvent(payload: NorelioWebhookPayload) {
  // Not a top-up: grant or close access your own way.
  if (payload.credits === null || payload.clientReference === null) return;

  const change = creditsChange(payload, payload.credits);
  if (change === 0) return;

  // One transaction: the event mark and the credits commit together or not at all.
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // Idempotency by eventId: a repeated delivery inserts nothing and credits nothing.
    const inserted = await client.query(
      "INSERT INTO norelio_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING",
      [payload.eventId],
    );
    if (inserted.rowCount === 1) {
      await client.query("UPDATE accounts SET credits = credits + $1 WHERE id = $2", [
        change,
        payload.clientReference,
      ]);
    }
    await client.query("COMMIT");
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

Refunds

Refunds do not remove credits by themselves: Norelio keeps no balance. You receive the same order.refunded_partial and order.refunded_full notifications as for any order and adjust the account on your side. Every refund notification carries refundedAmount — what this refund returned, without VAT, in the same units as amount — next to the order's amount and credits: remove floor(credits × refundedAmount ÷ amount), rounding down. Every refund is its own event with its own eventId, so two partial refunds of one order are two notifications and both remove credits; a repeated delivery of either one has the same eventId and removes nothing. The refund after which the whole amount has been returned arrives as order.refunded_full with the remainder in refundedAmount, so a single refund of the whole amount removes all credits of the order.

Example: the buyer paid 10.00 for 100 credits, and order.refunded_partial arrives with refundedAmount 400: remove floor(100 × 400 ÷ 1000) = 40 credits. When the remaining 6.00 is refunded too, order.refunded_full arrives with refundedAmount 600: remove floor(100 × 600 ÷ 1000) = 60 more.

order.refunded_partial for the same order
{
  "version": 1,
  "event": "order.refunded_partial",
  "test": false,
  "productId": "prod_7t0p9u",
  "customerEmail": "buyer@example.com",
  "paymentId": "ord_t0p4u9",
  "amount": 1000,
  "currency": "EUR",
  "taxAmount": 0,
  "occurredAt": "2026-03-16T11:02:10.000Z",
  "accessValidUntil": null,
  "clientReference": "acc_10294",
  "credits": 100,
  "creditsUnit": "credits",
  "refundedAmount": 400,
  "eventId": "cm8h2p5n80007ef3g6w1x9y2z"
}