# Norelio Developers

> Norelio sells digital products and subscriptions on behalf of its partners: a buy button or product card on the partner's site or a payment created from the partner's server, Norelio's own payment pages (card or PayPal), delivery of codes, files, texts, links or a webhook to the partner's server, subscriptions with renewals, top-ups (credits bought by package or by amount and reported in the webhook), an access check for subscription access, a customer state endpoint, a return URL and a client reference. Norelio is the merchant of record and pays the partner out.

Index: https://docs.norelio.eu/llms.txt. OpenAPI: https://docs.norelio.eu/openapi.json.

## Quickstart

Canonical address: https://docs.norelio.eu/quickstart

The first payment from your server in about fifteen minutes: API key, product, create the payment, send the user to pay, verify the webhook signature, read the customer state and credit a top-up exactly once, with code for Next.js, Node.js and Python.

From zero to the first payment in about fifteen minutes: your server creates the payment, sends the user to pay and receives a signed webhook. Every step shows the same code for Next.js (App Router), plain Node.js and Python (Flask); pick your stack with the switch above the code, and the choice applies to every block on the page. The API key and the webhook secret live only in your server's environment, never in code a browser can load.

> Prefer to let your coding agent do it? Give it the prompt from [Build with an AI agent](https://docs.norelio.eu/ai-agent).

### 1. Put the API key on your server

The partner issues the API key in the cabinet under API and webhooks → API key; it is shown once. Store it as the NORELIO_API_KEY environment variable of your server — not in the repository, not in a page, not in a mobile app. Every call sends it as Authorization: Bearer. One small helper makes the calls; the next steps add functions to it.

Server environment:

```bash
# API key: cabinet → API and webhooks → API key. Server environment only.
NORELIO_API_KEY=...
```

Next.js — lib/norelio.ts:

```ts
// Server only: never import this file into code that runs in the browser.
const NORELIO_API = "https://app.norelio.eu";

async function norelio<T>(path: string, init: RequestInit = {}): Promise<T> {
  // The key comes from the server environment (NORELIO_API_KEY).
  // Never name it NEXT_PUBLIC_NORELIO_API_KEY: such variables are bundled into browser code.
  const apiKey = process.env.NORELIO_API_KEY;
  if (!apiKey) throw new Error("NORELIO_API_KEY is not set");

  const response = await fetch(`${NORELIO_API}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
    cache: "no-store",
  });
  const body = await response.json();

  if (!response.ok) {
    // Every API error has one shape: { error: { code, message } }.
    throw new Error(`Norelio ${response.status} ${body.error?.code}: ${body.error?.message}`);
  }

  return body as T;
}
```

Node.js — norelio.js:

```javascript
// Server only: never import this file into code that runs in the browser.
const NORELIO_API = "https://app.norelio.eu";

async function norelio(path, init = {}) {
  // The key comes from the server environment (NORELIO_API_KEY).
  const apiKey = process.env.NORELIO_API_KEY;
  if (!apiKey) throw new Error("NORELIO_API_KEY is not set");

  const response = await fetch(`${NORELIO_API}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
  });
  const body = await response.json();

  if (!response.ok) {
    // Every API error has one shape: { error: { code, message } }.
    throw new Error(`Norelio ${response.status} ${body.error?.code}: ${body.error?.message}`);
  }

  return body;
}

exports.norelio = norelio;
```

Python — norelio.py:

```python
# Server only: never import this file into code that runs in the browser.
import os

import requests

NORELIO_API = "https://app.norelio.eu"


def norelio(method, path, **kwargs):
    # The key comes from the server environment (NORELIO_API_KEY).
    api_key = os.environ["NORELIO_API_KEY"]
    response = requests.request(
        method,
        f"{NORELIO_API}{path}",
        headers={"Authorization": f"Bearer {api_key}"},
        timeout=30,
        **kwargs,
    )
    body = response.json()

    if not response.ok:
        # Every API error has one shape: { error: { code, message } }.
        error = body.get("error", {})
        raise RuntimeError(f"Norelio {response.status_code} {error.get('code')}: {error.get('message')}")

    return body
```

### 2. Create the product

The partner creates the product in the cabinet catalog and gives you its id (NORELIO_PRODUCT_ID below). A product is one of three kinds:

- One-off purchase: one payment, one order.paid webhook.
- Subscription: the first payment and every renewal arrive as webhooks (subscription.renewed, subscription.ended and others); the access check and the customer state tell whether access is paid.
- Top-up: the buyer pays for credits — a package or an amount of their own; the request carries packageId or amount, and the webhook reports the credits (step 7).

> Before the first payment, the platform must enable payments from your server for the account: the partner asks support from the cabinet's Messages section with a link to their purchase terms, and the platform reviews them (see Create a payment from your server). Until then the endpoint answers 403 direct_checkout_not_enabled and creates no order. See [Create a payment from your server](https://docs.norelio.eu/checkouts).

Server environment:

```bash
# Product id from the cabinet catalog.
NORELIO_PRODUCT_ID=...
```

### 3. Create the payment from your server

POST /api/v1/checkouts with the product id and your own reference for the user's account — clientReference, required: the webhook brings it back, so you know whom to credit. Add the user's email if you know it and, for the consent record, the user's IP address and browser in buyer. The response carries the order id (the paymentId of the webhooks), url and expiresAt.

Next.js — lib/norelio.ts:

```ts
export type CheckoutRequest = {
  productId: string;
  clientReference: string;
  customerEmail?: string;
  returnUrl?: string;
  buyer?: { ip?: string; userAgent?: string };
  packageId?: number;
  amount?: number;
};

export type Checkout = {
  id: string;
  url: string;
  expiresAt: string;
  clientReference: string;
  warnings: { code: string; message: string }[];
  credits: number | null;
  creditsUnit: string | null;
};

export function createCheckout(body: CheckoutRequest) {
  return norelio<Checkout>("/api/v1/checkouts", { method: "POST", body: JSON.stringify(body) });
}
```

Node.js — norelio.js:

```javascript
exports.createCheckout = function createCheckout(body) {
  return norelio("/api/v1/checkouts", { method: "POST", body: JSON.stringify(body) });
};
```

Python — norelio.py:

```python
def create_checkout(body):
    return norelio("POST", "/api/v1/checkouts", json=body)
```

In full: [Create a payment from your server](https://docs.norelio.eu/checkouts), [Client reference](https://docs.norelio.eu/client-reference).

### 4. Send the user to the url

Answer the user's Pay click with a redirect (303) to url: a normal navigation in the user's browser, never a request from your server. The address is single-use and expires at expiresAt, so create a new payment for every click and do not write the address to logs. After paying, the user lands on the Norelio result page, and back on your site if the return URL is enabled.

Next.js — app/api/checkout/route.ts:

```ts
import { isIP } from "node:net";
import { getCurrentUser } from "@/lib/auth"; // your own sign-in: who is paying
import { createCheckout } from "@/lib/norelio";

export async function POST(request: Request) {
  const user = await getCurrentUser();
  if (!user) return new Response(null, { status: 401 });

  const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";

  const checkout = await createCheckout({
    productId: process.env.NORELIO_PRODUCT_ID!,
    clientReference: String(user.id),
    customerEmail: user.email,
    // The user's IP address and browser, for the consent record.
    buyer: {
      ip: isIP(ip) ? ip : undefined,
      userAgent: request.headers.get("user-agent") ?? undefined,
    },
    // Top-up product: add packageId (package number) or amount (minor units).
  });

  // A normal browser navigation to the payment page; the address is single-use.
  return Response.redirect(checkout.url, 303);
}
```

Next.js — app/pricing/page.tsx:

```ts
// The user's Pay button posts to the route above.
export default function PricingPage() {
  return (
    <form action="/api/checkout" method="post">
      <button type="submit">Pay</button>
    </form>
  );
}
```

Node.js — server.js:

```javascript
const http = require("node:http");
const net = require("node:net");
const { getCurrentUser } = require("./auth"); // your own sign-in: who is paying
const { createCheckout } = require("./norelio");

async function handlePay(req, res) {
  const user = await getCurrentUser(req);
  if (!user) return res.writeHead(401).end();

  const forwarded = String(req.headers["x-forwarded-for"] ?? "").split(",")[0].trim();
  const ip = forwarded || req.socket.remoteAddress;

  const checkout = await createCheckout({
    productId: process.env.NORELIO_PRODUCT_ID,
    clientReference: String(user.id),
    customerEmail: user.email,
    // The user's IP address and browser, for the consent record.
    buyer: { ip: net.isIP(ip) ? ip : undefined, userAgent: req.headers["user-agent"] },
    // Top-up product: add packageId (package number) or amount (minor units).
  });

  // A normal browser navigation to the payment page; the address is single-use.
  res.writeHead(303, { Location: checkout.url }).end();
}
```

Python — app.py:

```python
import ipaddress
import os

from flask import Flask, redirect, request

from auth import get_current_user  # your own sign-in: who is paying
from norelio import create_checkout

app = Flask(__name__)


def client_ip():
    ip = request.access_route[0] if request.access_route else ""
    try:
        return str(ipaddress.ip_address(ip))
    except ValueError:
        return None


@app.post("/api/checkout")
def pay():
    user = get_current_user()
    if user is None:
        return "", 401

    checkout = create_checkout({
        "productId": os.environ["NORELIO_PRODUCT_ID"],
        "clientReference": str(user.id),
        "customerEmail": user.email,
        # The user's IP address and browser, for the consent record.
        "buyer": {"ip": client_ip(), "userAgent": request.headers.get("User-Agent")},
        # Top-up product: add packageId (package number) or amount (minor units).
    })

    # A normal browser navigation to the payment page; the address is single-use.
    return redirect(checkout["url"], code=303)
```

In full: [Return URL](https://docs.norelio.eu/return-url).

### 5. Receive the webhook and verify the signature

The partner saves your endpoint's https address in the cabinet under API and webhooks → Webhooks, issues the signing secret there and sets the product's delivery method to "partner delivers (webhook)". Store the secret as NORELIO_WEBHOOK_SECRET. Verify every request before trusting it: an HMAC-SHA256 of {id}.{timestamp}.{raw body} with the secret, compared in constant time with the signature header, and a timestamp not older than 300 seconds. Read the RAW body: JSON re-serialised from a parsed object will not match. Answer 2xx once the event is processed; any other answer is retried. The test notification from the cabinet (test: true) exercises this endpoint without a purchase.

Server environment:

```bash
# Signing secret: cabinet → API and webhooks → Webhooks.
NORELIO_WEBHOOK_SECRET=...
```

Next.js — lib/norelio-webhook.ts:

```ts
import crypto from "node:crypto";

export function verifyNorelioWebhook(rawBody: string, headers: Headers, secret: string | undefined): boolean {
  const id = headers.get("x-norelio-webhook-id");
  const timestamp = headers.get("x-norelio-webhook-timestamp");
  const signature = headers.get("x-norelio-webhook-signature"); // "v1,<base64>"
  if (!id || !timestamp || !signature || !secret) return false;

  // not older than 300 seconds: replay protection
  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();
  const [scheme, value] = signature.split(",");
  if (scheme !== "v1" || !value) return false;
  const received = Buffer.from(value, "base64");

  // constant-time comparison: a plain equality check lets the signature
  // be guessed from the response time
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
```

Next.js — app/api/norelio/webhook/route.ts:

```ts
import { handleNorelioEvent } from "@/lib/norelio-events"; // step 7, or your own code for one-off purchases and subscriptions
import { verifyNorelioWebhook } from "@/lib/norelio-webhook";

export const runtime = "nodejs";

export async function POST(request: Request) {
  // The RAW body, before any JSON parsing: the signature covers exactly these bytes.
  const rawBody = await request.text();

  if (!verifyNorelioWebhook(rawBody, request.headers, process.env.NORELIO_WEBHOOK_SECRET)) {
    return new Response(null, { status: 401 });
  }

  const payload = JSON.parse(rawBody);

  // A test notification from the cabinet: nothing to grant or credit.
  if (payload.test !== true) {
    await handleNorelioEvent(payload);
  }

  return new Response(null, { status: 200 });
}
```

Node.js — norelio-webhook.js:

```javascript
const crypto = require("node:crypto");

function verifyNorelioWebhook(rawBody, headers, secret) {
  const id = headers["x-norelio-webhook-id"];
  const timestamp = headers["x-norelio-webhook-timestamp"];
  const signature = headers["x-norelio-webhook-signature"]; // "v1,<base64>"
  if (!id || !timestamp || !signature || !secret) return false;

  // not older than 300 seconds: replay protection
  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();
  const [scheme, value] = String(signature).split(",");
  if (scheme !== "v1" || !value) return false;
  const received = Buffer.from(value, "base64");

  // constant-time comparison: a plain equality check lets the signature
  // be guessed from the response time
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

exports.verifyNorelioWebhook = verifyNorelioWebhook;
```

Node.js — server.js:

```javascript
const { handleNorelioEvent } = require("./norelio-events"); // step 7, or your own code for one-off purchases and subscriptions
const { verifyNorelioWebhook } = require("./norelio-webhook");

function readRawBody(req) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

async function handleWebhook(req, res) {
  // The RAW body, before any JSON parsing: the signature covers exactly these bytes.
  const rawBody = await readRawBody(req);

  if (!verifyNorelioWebhook(rawBody, req.headers, process.env.NORELIO_WEBHOOK_SECRET)) {
    return res.writeHead(401).end();
  }

  const payload = JSON.parse(rawBody.toString("utf8"));

  // A test notification from the cabinet: nothing to grant or credit.
  if (payload.test !== true) {
    await handleNorelioEvent(payload);
  }

  res.writeHead(200).end();
}

const routes = {
  "POST /api/checkout": handlePay,
  "POST /norelio/webhook": handleWebhook,
};

http
  .createServer((req, res) => {
    const route = routes[`${req.method} ${req.url}`];
    if (!route) return res.writeHead(404).end();
    route(req, res).catch(() => res.writeHead(500).end());
  })
  .listen(Number(process.env.PORT) || 3000);
```

Python — norelio_webhook.py:

```python
import base64
import binascii
import hashlib
import hmac
import time


def verify_norelio_webhook(raw_body: bytes, headers, secret) -> bool:
    webhook_id = headers.get("x-norelio-webhook-id")
    timestamp = headers.get("x-norelio-webhook-timestamp")
    signature = headers.get("x-norelio-webhook-signature")  # "v1,<base64>"
    if not webhook_id or not timestamp or not signature or not secret:
        return False

    # not older than 300 seconds: replay protection
    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - sent_at) > 300:
        return False

    message = f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()

    scheme, _, value = signature.partition(",")
    if scheme != "v1" or not value:
        return False
    try:
        received = base64.b64decode(value, validate=True)
    except (ValueError, binascii.Error):
        return False

    # constant-time comparison: a plain equality check lets the signature
    # be guessed from the response time
    return hmac.compare_digest(received, expected)
```

Python — app.py:

```python
import json

from norelio_events import handle_norelio_event  # step 7, or your own code for one-off purchases and subscriptions
from norelio_webhook import verify_norelio_webhook


@app.post("/norelio/webhook")
def norelio_webhook():
    # The RAW body, before any JSON parsing: the signature covers exactly these bytes.
    raw_body = request.get_data()

    if not verify_norelio_webhook(raw_body, request.headers, os.environ.get("NORELIO_WEBHOOK_SECRET")):
        return "", 401

    payload = json.loads(raw_body)

    # A test notification from the cabinet: nothing to grant or credit.
    if payload.get("test") is not True:
        handle_norelio_event(payload)

    return "", 200
```

In full: [Webhooks](https://docs.norelio.eu/webhooks).

### 6. Read the customer's state

GET /api/v1/customers/state with clientReference (or email) returns the user's subscriptions and purchases, newest first: what to show on an account page. To decide whether a subscription is open right now, use the access check: it already accounts for the grace period, refunds and closed access.

Next.js — lib/norelio.ts:

```ts
export type CustomerState = {
  subscriptions: {
    id: string;
    productId: string;
    status: "active" | "past_due" | "canceled" | "ended";
    validUntil: string | null;
    currentPeriodEnd: string | null;
    cancelAtPeriodEnd: boolean;
    clientReference: string | null;
  }[];
  purchases: {
    orderId: string;
    productId: string | null;
    paidAt: string | null;
    amount: number;
    currency: string;
    status: "paid" | "refunded" | "disputed";
    clientReference: string | null;
    refunded: boolean;
    credits: number | null;
    creditsUnit: string | null;
  }[];
};

export function getCustomerState(clientReference: string) {
  const query = new URLSearchParams({ clientReference });
  return norelio<CustomerState>(`/api/v1/customers/state?${query}`);
}
```

Next.js — app/account/page.tsx:

```ts
import { getCurrentUser } from "@/lib/auth";
import { getCustomerState } from "@/lib/norelio";

export default async function AccountPage() {
  const user = await getCurrentUser();
  if (!user) return null;

  // Subscriptions and purchases of this user, newest first.
  const { purchases } = await getCustomerState(String(user.id));

  return (
    <ul>
      {purchases.map((purchase) => (
        <li key={purchase.orderId}>
          {purchase.productId}: {purchase.status}
          {purchase.credits !== null ? ` (${purchase.credits} ${purchase.creditsUnit})` : null}
        </li>
      ))}
    </ul>
  );
}
```

Node.js — norelio.js:

```javascript
// Subscriptions and purchases of this user, newest first.
exports.getCustomerState = function getCustomerState(clientReference) {
  const query = new URLSearchParams({ clientReference });
  return norelio(`/api/v1/customers/state?${query}`);
};
```

Python — norelio.py:

```python
# Subscriptions and purchases of this user, newest first.
def get_customer_state(client_reference):
    return norelio("GET", "/api/v1/customers/state", params={"clientReference": client_reference})
```

In full: [Customer state](https://docs.norelio.eu/customer-state), [Access check](https://docs.norelio.eu/access-check).

### 7. Top-ups: credit the account exactly once

For a top-up product, add exactly one of packageId (a package number) or amount (minor units) to the request of step 3. The order.paid webhook then carries credits and your clientReference. The same event can be delivered twice, so record its eventId with one atomic insert in the same database transaction that adds the credits: a repeat finds the eventId and changes nothing, a failure rolls both back and the retry credits again. On order.refunded_full remove the credits of that order; partial refunds are described in the Top-ups section.

Next.js — lib/norelio-events.ts:

```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") return -credits; // full refund: remove the credits of this order
  // Partial refunds: remove credits in proportion to the refunded share; see the Top-ups section.
  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();
  }
}
```

Node.js — norelio-events.js:

```javascript
const { Pool } = require("pg");

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

function creditsChange(payload, credits) {
  if (payload.event === "order.paid") return credits; // payment confirmed: add the credits
  if (payload.event === "order.refunded_full") return -credits; // full refund: remove the credits of this order
  // Partial refunds: remove credits in proportion to the refunded share; see the Top-ups section.
  return 0;
}

async function handleNorelioEvent(payload) {
  // 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();
  }
}

exports.handleNorelioEvent = handleNorelioEvent;
```

Python — norelio_events.py:

```python
import os

import psycopg

# Your database (PostgreSQL here) remembers processed events in this table:
# CREATE TABLE norelio_events (
#   event_id TEXT PRIMARY KEY
# );


def credits_change(payload):
    if payload["event"] == "order.paid":
        return payload["credits"]  # payment confirmed: add the credits
    if payload["event"] == "order.refunded_full":
        return -payload["credits"]  # full refund: remove the credits of this order
    # Partial refunds: remove credits in proportion to the refunded share; see the Top-ups section.
    return 0


def handle_norelio_event(payload):
    # Not a top-up: grant or close access your own way.
    if payload["credits"] is None or payload["clientReference"] is None:
        return

    change = credits_change(payload)
    if change == 0:
        return

    # One transaction: the event mark and the credits commit together or not at all.
    with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
        # Idempotency by eventId: a repeated delivery inserts nothing and credits nothing.
        inserted = conn.execute(
            "INSERT INTO norelio_events (event_id) VALUES (%s) ON CONFLICT DO NOTHING",
            (payload["eventId"],),
        ).rowcount
        if inserted == 1:
            conn.execute(
                "UPDATE accounts SET credits = credits + %s WHERE id = %s",
                (change, payload["clientReference"]),
            )
```

In full: [Top-ups](https://docs.norelio.eu/top-ups).

### Next

Next: send a test notification from the cabinet to check step 5, make one real purchase and find the order in the partner's cabinet. The rest of this documentation describes every field and every rule.

## Build with an AI agent

Canonical address: https://docs.norelio.eu/ai-agent

A ready prompt for Cursor, Claude Code or any coding agent: it reads llms.txt and the OpenAPI document and integrates Norelio by the rules that matter — the key on the server, payments created server-side, verified webhook signatures, every event processed once.

Your coding agent — Cursor, Claude Code or any agent that reads documentation — can do the integration. Paste the prompt below into the agent in your project. It sends the agent to llms.txt, the index of this documentation, and to the OpenAPI document, and names the rules it must not get wrong: the key stays on the server, payments are created server-side, webhook signatures are verified over the raw body, every event is processed once.

Prompt for your agent:

```text
You are connecting my application to Norelio. Norelio is the merchant of record: it takes the payment and pays me out.

Read https://docs.norelio.eu/llms.txt and the pages it links to. The OpenAPI document is https://docs.norelio.eu/openapi.json.
Ask me for the Norelio API key and the productId of each product. Keep the key on the server, in the environment variable NORELIO_API_KEY. Never put it in client code or logs.
When a user presses "Pay", create the payment from my server: POST https://app.norelio.eu/api/v1/checkouts with the header Authorization: Bearer <key> and the fields productId and clientReference (my id for that user). For a top-up product also send packageId or amount. Redirect the user's browser to the url in the response; it works once, so do not log it.
If the answer is 403 direct_checkout_not_enabled, stop and tell me: creating payments from my server has to be enabled for my account first.
Add a webhook endpoint and tell me its address: I save it in the Norelio cabinet. Verify the signature over the raw request body, deduplicate on eventId, and on order.paid credit the account named in clientReference (for a top-up, add the credits field). Handle refunds. For subscriptions, call the access check whenever the user comes back.
Tell me what you changed and how to test it with the test webhook from the Norelio cabinet.
```

The prompt asks the agent to request the API key (the partner issues it in the cabinet under API and webhooks → API key) and the id of each product (the cabinet catalog), to name the webhook endpoint it added — the partner saves that address under API and webhooks → Webhooks — and to explain how to test with the test notification from the cabinet. Review the change as you would a colleague's.

### What the agent reads

- [llms.txt](https://docs.norelio.eu/llms.txt) — The index: one line per section, with links. The prompt sends the agent here first.
- [OpenAPI 3.1](https://docs.norelio.eu/openapi.json) — The endpoints and every webhook payload as a machine-readable contract. The prompt names it too.
- [llms-full.txt](https://docs.norelio.eu/llms-full.txt) — This whole documentation in one Markdown file, every code example included, for an agent that prefers a single file.

To check the agent's work, follow the [Quickstart](https://docs.norelio.eu/quickstart): it shows the same integration step by step, in the language of your project.

## Overview

Canonical address: https://docs.norelio.eu

What Norelio does for a developer and what you can build today: payment button and checkout panel, subscriptions, webhooks, access check, return URL and client reference.

> New here? The [Quickstart](https://docs.norelio.eu/quickstart) takes you to the first payment from your server in about fifteen minutes, with code for Next.js, Node.js and Python. Or hand the job to your coding agent with the prompt from [Build with an AI agent](https://docs.norelio.eu/ai-agent).

Norelio sells digital products and subscriptions on behalf of its partners. A partner creates products in the Norelio cabinet; buyers pay on Norelio's own payment pages (card via Stripe or PayPal, depending on the partner's account); Norelio delivers what was bought — a code, a file, a text, a link, a manual service — or hands the delivery to your server with a webhook. Norelio is the merchant of record, handles VAT and pays the partner out.

This documentation is for the developer, or the AI agent, that connects a partner's application or site to Norelio. The partner — the account owner — works in the cabinet, which is in Russian; everything a developer needs is here, in English, and needs no login.

### What you can build today

- [Payment widget and checkout panel](https://docs.norelio.eu/widget) — A buy button or a product card on your own site, with a checkout panel the buyer pays from, or a direct payment link.
- Subscriptions — A product can be a one-off purchase or a subscription (daily, weekly, monthly or yearly, charged by card). You learn about renewals, failed charges, cancellations and the end of a subscription from the [webhooks](https://docs.norelio.eu/webhooks), and check whether access is currently paid with the [access check](https://docs.norelio.eu/access-check).
- [Webhooks](https://docs.norelio.eu/webhooks) — Signed notifications about payments, refunds, disputes and subscription events, delivered to your server with retries.
- [Access check](https://docs.norelio.eu/access-check) — A GET request from your server: is access for this email or account reference and product paid, and until when.
- [Create a payment from your server](https://docs.norelio.eu/checkouts) — A POST from your server with a product id and your account reference for the user; you get a payment address to send the user to, with no button or panel on a page.
- [Customer state](https://docs.norelio.eu/customer-state) — A GET request from your server: one buyer's subscriptions and purchases, by your account reference or by email.
- [Top-ups](https://docs.norelio.eu/top-ups) — Sell credits: the buyer picks a package or an amount, and the webhook tells your server how many credits to add to which account.
- [Return URL](https://docs.norelio.eu/return-url) — Bring the buyer back to your site after payment, like success_url at payment providers.
- [Client reference](https://docs.norelio.eu/client-reference) — Pass your own account reference at checkout and receive it back in every webhook for that order.

### Base URLs

- API — `https://app.norelio.eu` — the access check and the machine-readable schema live under `/api/public/`, the server API (create a payment, customer state) under `/api/v1/`.
- Payment pages — `https://pay.lis-system.eu/pay/{productId}` — where the widget and the direct payment link send the buyer.
- This documentation — `https://docs.norelio.eu` — also available as [llms.txt](https://docs.norelio.eu/llms.txt), [llms-full.txt](https://docs.norelio.eu/llms-full.txt) and [OpenAPI 3.1](https://docs.norelio.eu/openapi.json).

### What to ask the partner for

Everything below is issued or copied in the Norelio cabinet by the partner. None of it is created by the API.

- The product id (`productId`) of each product, shown in the cabinet catalog and in the payment link.
- A widget id for the buy button or card, created in the cabinet catalog under the product ("Site code").
- The API key for the access check and the server API, issued in the cabinet under API and webhooks → API key. It is shown once; keep it on your server only.
- To create payments from your server: the platform must have reviewed the partner's purchase terms and allowed direct checkout for the account (the partner asks support from the cabinet's Messages section).
- For webhooks: the partner saves your HTTPS endpoint and issues the signing secret in the cabinet under API and webhooks → Webhooks, and selects the delivery method "partner delivers (webhook)" on each product you fulfil yourself.

### A typical integration

1. Put the [widget snippet](https://docs.norelio.eu/widget) or the payment link on your site, or [create the payment from your server](https://docs.norelio.eu/checkouts) when your application already knows the user. If your users are signed in, add the [client reference](https://docs.norelio.eu/client-reference) so you know whom to credit.
2. Receive the [webhook](https://docs.norelio.eu/webhooks), verify its signature, process every `eventId` once and credit the account on `order.paid`. Treat refunds, disputes and subscription events accordingly.
3. For subscription products, call the [access check](https://docs.norelio.eu/access-check) when the user comes, instead of keeping your own calendar: it already accounts for renewals, grace, refunds and closed access. To show the user what they own, read the [customer state](https://docs.norelio.eu/customer-state).
4. Optionally enable the [return URL](https://docs.norelio.eu/return-url) so the buyer lands back on your site after paying.

> The partner can send a test webhook from the cabinet at any time; it carries `test: true` and a real `order.paid` event, so your handler is exercised end to end without a purchase.

## Webhooks

Canonical address: https://docs.norelio.eu/webhooks

Events, the notification payload, delivery rules, payload versioning and signature verification with Node.js and Python examples.

For every product whose delivery method is "partner delivers (webhook)", Norelio sends an HTTP POST to the endpoint the partner saved in the cabinet, for every event of every order of that product. The body is JSON (UTF-8); three headers carry the signature. The set of events cannot be narrowed: skipping a refund or the end of a subscription would leave access open with nobody told to close it.

### Events

- `order.paid` — Payment received — The buyer paid the order. Grant access. The order counts as delivered when this notification is DELIVERED, not when it is sent.
- `order.refunded_partial` — Partial refund — Part of the order amount was refunded to the buyer. Access usually stays open: this is not a full refund. For a subscription the access period is shortened in proportion to the refunded amount; the notification does not carry the new date, the access check does. refundedAmount says how much this refund returned; every partial refund is its own event with its own eventId.
- `order.refunded_full` — Full refund — The whole order amount was refunded to the buyer. Close the access paid by this order. For a subscription the access is shortened by the full period of this payment, counted from the current end date: if the new date has already passed, open access closes at once; if it is still ahead, access stays open until that date plus 2 hours. The access check gives the exact answer. refundedAmount carries the amount of this refund — the remainder, if partial refunds came before.
- `order.dispute_opened` — Dispute opened — The buyer disputed the payment with their bank or payment service. Until the dispute is resolved you may withhold access.
- `order.dispute_resolved` — Dispute resolved — The dispute was closed, in your favour or in the buyer's. Sent once, when the outcome is known; intermediate stages of a dispute produce no event. The notification does not say who won. A lost dispute on a subscription affects access like a refund of the same amount: ask the access check.
- `subscription.renewed` — Subscription renewed — A recurring charge succeeded and created a NEW order. Extend access for the new period. Usually arrives within an hour after the end of the paid period; for that time the access check keeps a grace of 2 hours after accessValidUntil. If you close access yourself by that date, keep the same grace. A renewal also reopens access closed by the seller, a full refund or a lost dispute.
- `subscription.ended` — Subscription ended — The subscription is over: the charge failed after all of the provider's retries, or the buyer, the partner or an administrator cancelled an OVERDUE subscription with no paid period left (such a cancellation ends the subscription at once). Close access. A normal cancellation during a paid period does not produce this event: subscription.canceled reports it, and subscription.ended follows when the period runs out.
- `subscription.payment_failed` — Charge failed — A recurring charge failed and the provider will retry. Access is not extended until a charge succeeds: it is paid only until accessValidUntil, and by the time of this event that date has usually passed; the access check closes access 2 hours after it. If access was already closed by the seller, a full refund or a lost dispute, accessValidUntil is the moment of closing and there is no grace after it. Use this event to warn the buyer: a successful retry sends subscription.renewed, exhausted retries send subscription.ended.
- `subscription.canceled` — Cancelled by the buyer — The buyer stopped the recurring charges. There will be no new charges, but access stays valid until the end of the paid period (see accessValidUntil; the access check closes it 2 hours after that date). Do not close access on this event: subscription.ended arrives separately when the period runs out. If access was already closed by the seller, a full refund or a lost dispute, accessValidUntil is the moment of closing and this event does not reopen it.

### Payload

Every notification has exactly these 16 keys, for every event alike; a key marked nullable is always present and may be `null`. No commission, partner share or hold figure is ever included.

| Field | Type | Description |
| --- | --- | --- |
| `version` | required | Payload version. Changes only on an incompatible change; see the compatibility commitment. |
| `event` | required | Which event happened: one of the values in the event catalog. |
| `test` | required | true for a test notification sent from the cabinet. Do not grant access on it; the event is real (order.paid), so parse it with your normal code. |
| `productId` | required | Your product id, the same as in the cabinet and in the payment page address. |
| `customerEmail` | nullable | The buyer's email. null when the payment arrived without one. |
| `paymentId` | required | Our order id. Every event of the order shares it — the payment, its refunds, two partial refunds — so recognise a repeated delivery by eventId. |
| `amount` | required | The product price in the smallest currency unit (cents), without VAT: 1000 means 10.00. |
| `currency` | required | Three-letter currency code, as the buyer saw it at checkout. |
| `taxAmount` | required | VAT charged to an EU buyer on top of the price, in the same unit and currency as amount; the buyer paid amount + taxAmount. 0 when no VAT applied: a buyer outside the EU, a business with a VAT id (reverse charge), a PayPal payment, or an order from before this field existed. Added without a version change. |
| `occurredAt` | required | When the event happened, ISO 8601, UTC. |
| `accessValidUntil` | nullable | Date and time until which access is paid, ISO 8601, UTC: the end of the paid period, without grace (the access check closes access 2 hours after it). For access closed by the seller, a full refund or a lost dispute, this is the moment of closing, with no grace after it; the notification does not distinguish the two, the access check does (hasAccess: false while the 2 hours have not passed yet). Present on subscription events (renewed, ended, payment_failed, canceled); null on order events. |
| `clientReference` | nullable | Your reference for the buyer's account, passed at checkout: the widget's data-client-reference attribute or the client_reference query parameter of the payment link. Present on every order event (payment, refunds, disputes); on subscription events it is the value of the order the subscription started with. null when nothing was passed or the value failed the format check (up to 128 characters: Latin letters, digits and ._:-, not starting with - or .). Set by the buyer's browser: treat it as a crediting address, not as proof of identity. |
| `credits` | nullable | The number of credits bought on a top-up order: an integer that Norelio took from the package or computed from the product's rate (a number sent by anyone is never accepted). null on every other order and on subscription events. Credit the account on order.paid; on every refund event (order.refunded_partial, order.refunded_full) remove the share this refund returned: floor(credits × refundedAmount ÷ amount). Added without a version change. |
| `creditsUnit` | nullable | The name of the unit as the partner set it on the product ("credits", "tokens"); null wherever credits is null. |
| `refundedAmount` | nullable | The amount this refund returned to the buyer, without VAT, in the same unit and currency as amount. Present on order.refunded_partial and order.refunded_full; null on every other event. Every refund is its own event with its own amount: two partial refunds of one order are two notifications, and the refund after which everything has been returned arrives as order.refunded_full with the remainder. Added without a version change. |
| `eventId` | required | Our id of this event. The same on every delivery of the event — automatic retries and a resend from the cabinet's delivery log alike — and different for every other event: two partial refunds of one order have different ids. Deduplicate on it. Treat it as an opaque string; do not parse it. A test notification has its own id, starting with test_. The x-norelio-webhook-id header is not for this: it names one delivery attempt and is part of its signature. Added without a version change. |

Example payload (version 1):

```json
{
  "version": 1,
  "event": "order.paid",
  "test": false,
  "productId": "prod_1a2b3c",
  "customerEmail": "buyer@example.com",
  "paymentId": "ord_9z8y7x",
  "amount": 1990,
  "currency": "EUR",
  "taxAmount": 418,
  "occurredAt": "2026-03-14T09:26:53.000Z",
  "accessValidUntil": null,
  "clientReference": "acc_10294",
  "credits": null,
  "creditsUnit": null,
  "refundedAmount": null,
  "eventId": "cm8f3k2q90001ab7d9x2y4z6w"
}
```

The amount is what the buyer saw at checkout, in the buyer's currency. Verify the signature before doing anything with the content: without it the body is just what somebody posted to your address.

> A test notification is indistinguishable from a real payment except for one field. The partner's "send a test notification" button in the cabinet posts exactly this shape, with the real `order.paid` event and your real signature, but with made-up data and `test: true`. Recognise a test by `test`, never by `event` or by the other fields: a handler that ignores the field grants access to a buyer who does not exist.

Example test payload:

```json
{
  "version": 1,
  "event": "order.paid",
  "test": true,
  "productId": "test_product",
  "customerEmail": "buyer@example.com",
  "paymentId": "test_1773480413000",
  "amount": 1990,
  "currency": "EUR",
  "taxAmount": 0,
  "occurredAt": "2026-03-14T09:26:53.000Z",
  "accessValidUntil": null,
  "clientReference": "acc_10294",
  "credits": null,
  "creditsUnit": null,
  "refundedAmount": null,
  "eventId": "test_cm8f3k2q90005gh2j4k6m8n0p"
}
```

### Delivery rules

A successful response is any 2xx status, and only the status: we do not read a byte of your response body. Anything else — a non-2xx status, a network error, no response within 5 seconds — is a failure, and we retry.

A failure is retried with a growing delay: 2 minutes, 10 minutes, 1 hour, 4 hours, 12 hours after the previous attempt — 6 attempts in total, spread over roughly a day. We wait up to 5 seconds for your response. The partner sees the outcome of every attempt in the cabinet's delivery log and can retry a failed delivery by hand.

Delivery is at-least-once, not exactly-once: a cut between our request and the record of its outcome cannot be ruled out, so the same event occasionally arrives twice. Deduplicate on eventId — the same on every delivery of one event, different for every other event — and treat a repeat as an event you have already processed, not as a new one. Do not use paymentId and event for this: two partial refunds of one order share both. Do not use the x-norelio-webhook-id header either: it names one delivery attempt and is part of its signature.

Order BETWEEN different events of one order is not guaranteed: every event has its own independent retry queue, so in rare cases a refund can arrive before the payment it refunds, for example while that payment's delivery is being retried after your server was unavailable. Rely on occurredAt and on the meaning of the event, not on the order of arrival.

An order counts as delivered when the `order.paid` notification is DELIVERED, not when it is sent. Until your server answers, the order is shown as waiting in the partner's cabinet, and after the attempts are exhausted it is flagged for review there.

### Versioning and compatibility

The `version` field names the payload version; it is currently 1. Payload fields are only ever ADDED: we never rename or remove an existing field and never change its meaning. An incompatible change is rare and always increments version. Your code can safely ignore fields it does not know: a new field never requires changing a check you have already written, and a version you have already seen keeps arriving in the same shape.

The same contract as a formal JSON Schema (draft 2020-12), for a tool that generates code from a schema rather than from this page: `GET https://app.norelio.eu/api/public/webhook-schema` (its descriptions are in Russian, like the cabinet). The English equivalent is the [OpenAPI document](https://docs.norelio.eu/openapi.json), which describes the same payload under `webhooks`. See also the [Changelog](https://docs.norelio.eu/changelog).

### Signature

Every request — test and real alike — carries three headers, signed with the partner's webhook secret from the cabinet: `x-norelio-webhook-id` (the delivery attempt id), `x-norelio-webhook-timestamp` (the send time, in seconds since the Unix epoch) and `x-norelio-webhook-signature` (the signature, in the form `v1,<base64>`).

1. Build the string {id}.{timestamp}.{raw request body}.
2. Compute an HMAC-SHA256 of it with your secret and encode the result in base64.
3. Compare the result with the part after the comma in the signature header.
4. Check that the timestamp is not older than 300 seconds, so a captured request cannot be replayed.

Sign the body exactly as it arrived: compute the signature over the RAW request body, before parsing the JSON. A body re-serialised from a parsed object differs in whitespace and key order, and the signature will not match.

Node.js:

```javascript
const crypto = require("crypto");

function isValid(rawBody, headers, secret) {
  const id = headers["x-norelio-webhook-id"];
  const timestamp = headers["x-norelio-webhook-timestamp"];
  const signature = headers["x-norelio-webhook-signature"]; // "v1,<base64>"

  // not older than 300 seconds: replay protection
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  const [version, value] = String(signature).split(",");
  if (version !== "v1" || !value) return false;

  const received = Buffer.from(value, "base64");

  // constant-time comparison: a plain equality check lets the signature
  // be guessed from the response time
  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(received, expected)
  );
}
```

Python:

```python
import base64
import binascii
import hashlib
import hmac
import time


def is_valid(raw_body: bytes, headers: dict, secret: str) -> bool:
    webhook_id = headers["x-norelio-webhook-id"]
    timestamp = headers["x-norelio-webhook-timestamp"]
    signature = headers["x-norelio-webhook-signature"]  # "v1,<base64>"

    # not older than 300 seconds: replay protection
    if abs(time.time() - float(timestamp)) > 300:
        return False

    message = f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()

    version, _, value = signature.partition(",")
    if version != "v1" or not value:
        return False

    try:
        received = base64.b64decode(value, validate=True)
    except (ValueError, binascii.Error):
        return False

    # constant-time comparison: a plain equality check lets the signature
    # be guessed from the response time
    return hmac.compare_digest(received, expected)
```

- Compare signatures in constant time, as both examples do: a plain equality check leaks the signature through the response time.
- Reject a request whose timestamp is older than 300 seconds: that is what stops a captured request from being replayed.
- The attempt id in the header identifies the delivery attempt, not the event: a retry carries a new id and a new signature.
- The secret never travels in the notification. The partner sees it once, when it is issued in the cabinet; a fingerprint shown there lets them confirm the secret you hold is the current one. Retries of notifications created before the secret was reissued are signed with the new secret.

## Access check

Canonical address: https://docs.norelio.eu/access-check

Ask from your server whether a buyer's subscription access is paid and until when: request, response, grace period and error codes.

Your server asks Norelio whether subscription access for a given buyer email and product is paid, and until when. The request is made from your server with the partner's API key: never embed the key in a page a browser can read.

### Request

```http
GET https://app.norelio.eu/api/public/access-check?email={email}&productId={productId}
GET https://app.norelio.eu/api/public/access-check?clientReference={reference}&productId={productId}
Authorization: Bearer {api key}
```

| Parameter | Where | Description |
| --- | --- | --- |
| `email` | query, one of the two | The buyer's email to check. Either it or clientReference is required. |
| `clientReference` | query, one of the two | Your account reference for the buyer, passed at checkout (the client reference). Either it or email is required; with both, either may match. |
| `productId` | query, required | Your product id. |
| `Authorization` | header, required | The access-check key issued in the cabinet (API and webhooks, API key tab), sent from your server. |

Example by email:

```bash
curl -s "https://app.norelio.eu/api/public/access-check?email=buyer%40example.com&productId=prod_1a2b3c" \
  -H "Authorization: Bearer $NORELIO_API_KEY"

{"hasAccess":true,"validUntil":"2026-04-14T09:26:53.000Z","clientReference":"acc_10294"}
```

Example by client reference:

```bash
curl -s "https://app.norelio.eu/api/public/access-check?clientReference=acc_10294&productId=prod_1a2b3c" \
  -H "Authorization: Bearer $NORELIO_API_KEY"

{"hasAccess":true,"validUntil":"2026-04-14T09:26:53.000Z","clientReference":"acc_10294"}
```

Instead of the email you can pass your own account reference for the buyer: the clientReference query parameter, the same value you passed at checkout (see the client reference section). One of the two is required; with both, either may match: when the email has no access, the emails of this product's orders carrying that reference are checked. The response has a third field, clientReference: the reference of this email's latest order for this product (for a subscription renewal, of the order the subscription started with), or null. Existing calls with an email alone keep working unchanged.

### Response

The response always has three fields: `hasAccess` (boolean), `validUntil` (ISO 8601 date-time or `null`) and `clientReference` (string or `null`). You can ask only about the partner's own products and buyers: the partner is identified by the key, not by a request parameter — there is no such parameter.

| Field | Type | Description |
| --- | --- | --- |
| `hasAccess` | boolean | Whether access is open for this email/product pair right now: the paid period is still running, or less than 120 minutes have passed since its end (validUntil) — the grace for a renewal, which arrives within about an hour after the period ends. Access closed by the seller, a full refund or a lost dispute is closed at once, without grace. |
| `validUntil` | string (date-time) or null | End of the paid period, without grace; for access closed by the seller, a full refund or a lost dispute, the moment of closing; null for a non-subscription or non-existent access. |
| `clientReference` | string or null | The account reference of the access found: the reference of this email's latest order for this product (for a subscription renewal, of the order the subscription started with); null when there is none or there is no access. |

Access is paid:

```json
{
  "hasAccess": true,
  "validUntil": "2026-04-14T09:26:53.000Z",
  "clientReference": "acc_10294"
}
```

No access:

```json
{
  "hasAccess": false,
  "validUntil": null,
  "clientReference": null
}
```

`{"hasAccess":false,"validUntil":null,"clientReference":null}` is the one answer for everything that is not a paid access: the buyer never paid, the product is not a subscription, or there is no such product. The response does not distinguish these cases. Access that existed and then closed answers `hasAccess: false` with a past date in `validUntil`.

### Grace period and closed access

A recurring subscription charge arrives within about an hour after the end of the paid period. So that the buyer does not lose access for that time, the access check keeps a grace of 2 hours: hasAccess stays true for 2 hours after validUntil, while validUntil remains the date until which access is paid, without grace. If the renewal arrives, validUntil moves to the new period; if it does not, 2 hours after validUntil the answer becomes hasAccess: false.

If you store the date yourself — for example from the accessValidUntil field of the notifications — and decide by it on your side, keep the same grace: access is open for 2 hours after that date. Otherwise a buyer whose renewal is about to arrive loses access for that time. The exception is access closed deliberately (next paragraph): it has no grace.

Access closed by the partner in the cabinet (the "close access" action in the subscribers list) closes at once, without grace: from that moment the answer is hasAccess: false, and validUntil is the moment of closing. When the whole payment went back to the buyer — a full refund or a lost dispute — access is shortened by the full period paid by that payment, counted from the current validUntil, whichever payment set it. If the new date has already passed while access is still open, it closes the same way, at once and without grace, and validUntil is the moment of closing; this also happens with an earlier payment when less than its period is paid ahead. If the new date is still ahead, access is open until it plus 2 hours. Access that is already closed at that moment stays closed.

A partial refund deliberately does not close access: validUntil moves earlier in proportion to the refunded amount, and the 2 hours grace after the new date applies as usual. If the new date passed more than 2 hours ago, access is closed at once by the same rule.

Closed access is reopened by the next payment — a subscription renewal or a new purchase — and by the partner's "extend by a period" action in the cabinet; after that the grace applies again. Access closed by a full refund or a lost dispute returns to its previous date and the usual grace when that refund did not go through after all or the dispute was reversed in the seller's favour, provided nothing happened to the access after it was closed.

The most reliable approach is therefore to ask the access check every time the buyer comes: it already accounts for the grace, closing and refunds. If you still store the date yourself, remember that subscription events are not the only ones that change it: refund and dispute notifications (order.refunded_full, order.refunded_partial, order.dispute_resolved) carry no new date, and a cancelled refund or a dispute reversed in the seller's favour sends no notification at all. Compare the stored date with the access check after refund and dispute notifications and before refusing a returning buyer. An answer of hasAccess: false while the 2 hours after validUntil have not passed yet means access was closed deliberately: do not reopen it by that date.

> In short: the paid period plus 2 hours of grace, except for access closed deliberately — by the partner, by a full refund or by a lost dispute — which closes at once. Ask the access check every time the user comes; it already knows.

### Errors

| Status | When |
| --- | --- |
| `400` | the productId query parameter is missing, or neither email nor clientReference is given |
| `401` | the key is missing or does not match |
| `429` | too many requests in a row; the Retry-After header says in how many seconds to retry |

The response is never cached on our side: it concerns one buyer of one partner. Do not cache it on yours. Requests are rate-limited per client address and per key; a `429` answer names the number of seconds to wait in `Retry-After`.

## Create a payment from your server

Canonical address: https://docs.norelio.eu/checkouts

POST a product id and your account reference from your server and get a payment address to send the user to: request, response, warnings, error codes and the full flow with the webhook.

The server API is for a partner whose application knows its own user: a payment is created from your server, with no button or panel on a page, and the user's state (subscriptions and purchases) is read by your own account reference. The key is the same as for the access check (API key tab in the cabinet), sent as Authorization: Bearer; the partner is identified by the key, there is no partner parameter. Rate limits are the same as for the access check: per your server's address and per key. Responses are JSON only and never cached. You can ask only about your own products and buyers: a foreign product or buyer and a non-existent one answer alike.

POST /api/v1/checkouts creates an order and returns the address to send the user to. The product is named by its id from the cabinet catalog; your account reference for the user is required, since without it the payment cannot be credited to anyone. From there everything works as for a widget purchase: the order.paid webhook with that reference, the result page, the return to your site.

> The endpoint works only after the platform has reviewed your purchase terms and allowed direct checkout for your account (see the direct checkout section): the buyer never sees a Norelio page before paying, so the consent to the purchase terms — and, for a subscription, to the recurring charges — is collected by your application before the call. What your terms must contain is described there. Without the permission the endpoint answers 403 direct_checkout_not_enabled and creates no order. For every order Norelio stores that the consent was collected in your application, the version and text of the terms, the date and author of the permission and, if you passed them, the buyer's IP address and browser: this record answers a payment dispute, so pass the buyer field. See [direct checkout](https://docs.norelio.eu/widget) for what your terms must contain.

### Request

```http
POST https://app.norelio.eu/api/v1/checkouts
Authorization: Bearer {api key}
Content-Type: application/json
```

| Field | Description |
| --- | --- |
| `productId` | Required. The product id from the cabinet catalog. |
| `clientReference` | Required. Your account reference for the user, in the client reference format: up to 128 characters, Latin letters, digits and . _ : -, not starting with "-" or ".". A malformed value is a 400 error, not an empty field: here there is no buyer who has already pressed "Pay". |
| `customerEmail` | Optional. The user's email, if you know it: it is pre-filled in the payment provider's form so the buyer does not type it. The order's email is still set by the payment, from the provider's data. |
| `returnUrl` | Optional. Where to bring the buyer after payment, by the rules of the return URL section: an https address on one of the return sites allowed for your account, up to 2000 characters. An address that fails the rules does not stop the purchase: the order is created without a return address and the response carries a warning. |
| `buyer` | Optional. An object with ip (the buyer's IPv4 or IPv6 address) and userAgent (the buyer's browser): what your server knows about the user's request. Both go into the consent record; your server's own address never does. An invalid ip is a 400 error. |
| `packageId` | Top-up products only: the package number from the product card (1 to 6, in the order the partner listed them). Exactly one of packageId and amount for a top-up; neither for any other product — otherwise 400 invalid_request. The server takes the price and the credits from the package itself. |
| `amount` | Top-up products only: the amount to pay in the smallest currency unit of the product's currency (1250 means 12.50), within the range the partner set for free amounts. The server checks the range and computes the credits by the product's rate. Exactly one of packageId and amount for a top-up; neither for any other product. |

Example request body:

```json
{
  "productId": "prod_1a2b3c",
  "clientReference": "acc_10294",
  "customerEmail": "buyer@example.com",
  "returnUrl": "https://pawclaw.com/thank-you",
  "buyer": {
    "ip": "203.0.113.7",
    "userAgent": "Mozilla/5.0 (X11; Linux x86_64)"
  }
}
```

Example:

```bash
curl -s -X POST "https://app.norelio.eu/api/v1/checkouts" \
  -H "Authorization: Bearer $NORELIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"productId":"prod_1a2b3c","clientReference":"acc_10294","customerEmail":"buyer@example.com","returnUrl":"https://pawclaw.com/thank-you","buyer":{"ip":"203.0.113.7","userAgent":"Mozilla/5.0 (X11; Linux x86_64)"}}'
```

### Response

`201 Created` with these fields:

| Field | Description |
| --- | --- |
| `id` | The order id — the same paymentId that arrives in the webhook. |
| `url` | The address to send the user to. It is on Norelio's payment domain: the page issues the buyer's receipt key, stores it in a cookie and immediately forwards them to the payment provider. The address is single-use: it can be opened once. |
| `expiresAt` | Until when the address still accepts the buyer (ISO 8601): the payment provider's session lifetime. After it, create a new payment. |
| `clientReference` | The account reference stored on the order — the one you passed. |
| `warnings` | Warnings, empty when everything was accepted. Each is an object with code and message. |
| `credits` | Top-ups: the number of credits this payment buys, computed by Norelio from the package or the amount; null for any other product. The same number arrives in the webhook. |
| `creditsUnit` | Top-ups: the unit name from the product card; null for any other product. |

Example response:

```json
{
  "id": "ord_7k3m2p",
  "url": "https://pay.lis-system.eu/api/public/checkout/continue?order=ord_7k3m2p&key=k4Xw1QmR9zL2nT8vB0cY7pH5sJ3eF6gD",
  "expiresAt": "2026-03-14T10:26:53.000Z",
  "clientReference": "acc_10294",
  "warnings": []
}
```

Open the url from the response in the user's browser as a normal navigation (a link or a redirect), not as a request from your server: the page at that address issues the buyer's receipt key, stores it in a cookie — as the checkout panel and direct checkout do — and forwards them to the payment provider. The address is SINGLE-USE: it carries a separate handoff key that is spent on the first open; a second open is refused. The receipt key never travels in the address — it is issued in the buyer's browser. Show the address to that user only and do not write it to logs: whoever opens it first receives the receipt key. The address expires at expiresAt. The result page and the return to your site after payment work as with direct checkout; on the Stripe page a line under the pay button links to the purchase terms.

### Warnings

| Code | When |
| --- | --- |
| `return_url_rejected` | the returnUrl you passed failed the check; the order was created without a return address |

Example response with a warning:

```json
{
  "id": "ord_7k3m2p",
  "url": "https://pay.lis-system.eu/api/public/checkout/continue?order=ord_7k3m2p&key=k4Xw1QmR9zL2nT8vB0cY7pH5sJ3eF6gD",
  "expiresAt": "2026-03-14T10:26:53.000Z",
  "clientReference": "acc_10294",
  "warnings": [
    {
      "code": "return_url_rejected",
      "message": "returnUrl was ignored: it is not an https address on one of the return sites allowed for your account. The payment was created without a return address."
    }
  ]
}
```

### Repeated calls and limits

A repeated call with the same data creates a NEW payment rather than returning the previous one: there is no idempotency. Orders the user never paid are removed by Norelio's nightly cleanup; they never enter payouts or the customer state.

On top of the per-address and per-key limits, the payment-attempt limit per buyer address — the same one the panel and the button use — applies when you pass buyer.ip. Without buyer.ip no per-buyer limit is counted: the request's address is your server's.

### Errors

An error from any API endpoint is JSON of one shape: an error object with code (one of the codes below) and message (a short English sentence). A 429 response carries a Retry-After header. The access check keeps its own, older error shape.

| Status | Code | When |
| --- | --- | --- |
| `429` | `rate_limited` | too many requests in a row, from your address or with your key |
| `401` | `invalid_api_key` | the key is missing or does not match |
| `400` | `invalid_request` | the body or the query is malformed; message says what exactly |
| `403` | `direct_checkout_not_enabled` | creating payments from your server is not yet enabled for your account: the platform first reviews your purchase terms (the same review as for direct checkout) |
| `404` | `product_not_found` | no product with this id belongs to your account, or it cannot be bought right now |
| `502` | `checkout_failed` | the payment provider could not start the payment; retry later |
| `404` | `not_found` | no such endpoint |
| `405` | `method_not_allowed` | the endpoint does not accept that method |

Example error:

```json
{
  "error": {
    "code": "direct_checkout_not_enabled",
    "message": "Creating payments from your server is enabled after the platform has reviewed your purchase terms. Ask support from the cabinet's Messages section."
  }
}
```

### The whole flow

How it fits together for an application with a subscription:

1. The user presses "Pay" in your application. Your server calls the create-payment endpoint with productId, your account reference for the user and, when known, the user's email, IP address and browser.
2. Send the user to the url from the response as a normal browser navigation. They pay on the provider's page, land on the Norelio result page and, with the return enabled, come back to you.
3. Your server receives the order.paid webhook with paymentId (the id from the response) and clientReference — your account reference. Verify the signature and credit that account.
4. When the user comes back, ask the customer state by clientReference, or the access check by product: both already account for renewals, refunds, grace and closed access.

The webhook is described in [Webhooks](https://docs.norelio.eu/webhooks), the state in [Customer state](https://docs.norelio.eu/customer-state), the access check in [Access check](https://docs.norelio.eu/access-check).

## Customer state

Canonical address: https://docs.norelio.eu/customer-state

Read one buyer's subscriptions and purchases by your account reference or by email: fields, statuses and limits.

GET /api/v1/customers/state returns the subscriptions and purchases of one of your buyers: what the partner sees in the cabinet's subscribers and order lists, for one person and machine-readable. It answers 200 always; for an unknown (or foreign) buyer both lists are empty.

The server API is for a partner whose application knows its own user: a payment is created from your server, with no button or panel on a page, and the user's state (subscriptions and purchases) is read by your own account reference. The key is the same as for the access check (API key tab in the cabinet), sent as Authorization: Bearer; the partner is identified by the key, there is no partner parameter. Rate limits are the same as for the access check: per your server's address and per key. Responses are JSON only and never cached. You can ask only about your own products and buyers: a foreign product or buyer and a non-existent one answer alike.

### Request

```http
GET https://app.norelio.eu/api/v1/customers/state?clientReference={reference}
GET https://app.norelio.eu/api/v1/customers/state?email={email}
Authorization: Bearer {api key}
```

Exactly one query parameter: clientReference (your account reference, same format) or email (the buyer's email, lower-cased as in the cabinet). Both or neither is a 400 error. By account reference, the orders carrying it are found together with the subscriptions those orders started, including their renewals.

Example by client reference:

```bash
curl -s "https://app.norelio.eu/api/v1/customers/state?clientReference=acc_10294" \
  -H "Authorization: Bearer $NORELIO_API_KEY"

{"subscriptions":[{"id":"sub_4d5e6f","productId":"prod_1a2b3c","status":"active","validUntil":"2026-04-14T09:26:53.000Z","currentPeriodEnd":"2026-04-14T09:26:53.000Z","cancelAtPeriodEnd":false,"clientReference":"acc_10294"}],"purchases":[{"orderId":"ord_7k3m2p","productId":"prod_1a2b3c","paidAt":"2026-03-14T09:26:53.000Z","amount":1990,"currency":"EUR","status":"paid","clientReference":"acc_10294","refunded":false},{"orderId":"ord_9z8y7x","productId":"prod_4d5e6f","paidAt":"2026-02-01T18:03:11.000Z","amount":990,"currency":"EUR","status":"refunded","clientReference":"acc_10294","refunded":true}]}
```

Example by email:

```bash
curl -s "https://app.norelio.eu/api/v1/customers/state?email=buyer%40example.com" \
  -H "Authorization: Bearer $NORELIO_API_KEY"
```

### Response

`200 OK` with two lists. Each subscription:

| Field | Description |
| --- | --- |
| `id` | The subscription id at Norelio. |
| `productId` | The product id. |
| `status` | The subscription state as in the buyer's account: active — charges are running; past_due — the last charge failed and the provider is retrying; canceled — charges stopped, the paid period runs out; ended — the subscription is over. |
| `validUntil` | Until when access is paid (ISO 8601): the same date the access check returns, without grace; null when there is no access. It is the access date, not the next charge: a refund or a closed access shortens it. |
| `currentPeriodEnd` | End of the current paid period as the provider reports it, i.e. the next charge; null when unknown. |
| `cancelAtPeriodEnd` | true when charges have been stopped and the subscription runs out its period. |
| `clientReference` | The account reference of the order the subscription started with, or null. |

Each purchase:

| Field | Description |
| --- | --- |
| `orderId` | The order id — the same paymentId as in the webhooks. |
| `productId` | The product id; null for a payment without a product. |
| `paidAt` | When the payment was confirmed (ISO 8601); null while it is not. |
| `amount` | The price shown to the buyer, in the smallest currency unit (cents), without VAT — like amount in the webhook. |
| `currency` | The currency of that price, three-letter code. |
| `status` | paid, refunded (in full) or disputed. |
| `clientReference` | The account reference by the client reference rule: for a subscription renewal, the one of the order the subscription started with; null when none was passed. |
| `refunded` | true when money went back to the buyer, in full or in part. |
| `credits` | For a top-up purchase, the number of credits bought (from the order's snapshot); null for any other purchase. |
| `creditsUnit` | For a top-up purchase, the unit name; null for any other purchase. |

Each list holds up to 50 rows, newest first. Purchases include only orders on which money actually moved: paid, refunded and disputed ones; unpaid and cancelled attempts are not shown.

Example response:

```json
{
  "subscriptions": [
    {
      "id": "sub_4d5e6f",
      "productId": "prod_1a2b3c",
      "status": "active",
      "validUntil": "2026-04-14T09:26:53.000Z",
      "currentPeriodEnd": "2026-04-14T09:26:53.000Z",
      "cancelAtPeriodEnd": false,
      "clientReference": "acc_10294"
    }
  ],
  "purchases": [
    {
      "orderId": "ord_7k3m2p",
      "productId": "prod_1a2b3c",
      "paidAt": "2026-03-14T09:26:53.000Z",
      "amount": 1990,
      "currency": "EUR",
      "status": "paid",
      "clientReference": "acc_10294",
      "refunded": false
    },
    {
      "orderId": "ord_9z8y7x",
      "productId": "prod_4d5e6f",
      "paidAt": "2026-02-01T18:03:11.000Z",
      "amount": 990,
      "currency": "EUR",
      "status": "refunded",
      "clientReference": "acc_10294",
      "refunded": true
    }
  ]
}
```

Unknown buyer:

```json
{
  "subscriptions": [],
  "purchases": []
}
```

### Errors

An error from any API endpoint is JSON of one shape: an error object with code (one of the codes below) and message (a short English sentence). A 429 response carries a Retry-After header. The access check keeps its own, older error shape.

| Status | Code | When |
| --- | --- | --- |
| `429` | `rate_limited` | too many requests in a row, from your address or with your key |
| `401` | `invalid_api_key` | the key is missing or does not match |
| `400` | `invalid_request` | the body or the query is malformed; message says what exactly |
| `403` | `direct_checkout_not_enabled` | creating payments from your server is not yet enabled for your account: the platform first reviews your purchase terms (the same review as for direct checkout) |
| `404` | `product_not_found` | no product with this id belongs to your account, or it cannot be bought right now |
| `502` | `checkout_failed` | the payment provider could not start the payment; retry later |
| `404` | `not_found` | no such endpoint |
| `405` | `method_not_allowed` | the endpoint does not accept that method |

Example error:

```json
{
  "error": {
    "code": "direct_checkout_not_enabled",
    "message": "Creating payments from your server is enabled after the platform has reviewed your purchase terms. Ask support from the cabinet's Messages section."
  }
}
```

## Top-ups

Canonical address: https://docs.norelio.eu/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](https://docs.norelio.eu/widget).

Package data-package:

```html
<div data-lis-widget="wgt_1a2b3c" data-client-reference="acc_10294" data-package="2"></div>
```

Amount data-amount, minor units:

```html
<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](https://docs.norelio.eu/checkouts).

Request body with a package:

```json
{
  "productId": "prod_7t0p9u",
  "clientReference": "acc_10294",
  "customerEmail": "buyer@example.com",
  "packageId": 2
}
```

Request body with an amount:

```json
{
  "productId": "prod_7t0p9u",
  "clientReference": "acc_10294",
  "customerEmail": "buyer@example.com",
  "amount": 1250
}
```

Example response:

```json
{
  "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:

```json
{
  "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](https://docs.norelio.eu/webhooks).

order.paid for a top-up:

```json
{
  "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](https://docs.norelio.eu/quickstart#step-webhook).

Next.js — lib/norelio-events.ts:

```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();
  }
}
```

Node.js — norelio-events.js:

```javascript
const { Pool } = require("pg");

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

function creditsChange(payload, credits) {
  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;
}

async function handleNorelioEvent(payload) {
  // 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();
  }
}

exports.handleNorelioEvent = handleNorelioEvent;
```

Python — norelio_events.py:

```python
import os

import psycopg

# Your database (PostgreSQL here) remembers processed events in this table:
# CREATE TABLE norelio_events (
#   event_id TEXT PRIMARY KEY
# );


def credits_change(payload):
    if payload["event"] == "order.paid":
        return payload["credits"]  # payment confirmed: add the credits
    if payload["event"] in ("order.refunded_full", "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 -(payload["credits"] * (payload.get("refundedAmount") or 0) // payload["amount"])
    return 0


def handle_norelio_event(payload):
    # Not a top-up: grant or close access your own way.
    if payload["credits"] is None or payload["clientReference"] is None:
        return

    change = credits_change(payload)
    if change == 0:
        return

    # One transaction: the event mark and the credits commit together or not at all.
    with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
        # Idempotency by eventId: a repeated delivery inserts nothing and credits nothing.
        inserted = conn.execute(
            "INSERT INTO norelio_events (event_id) VALUES (%s) ON CONFLICT DO NOTHING",
            (payload["eventId"],),
        ).rowcount
        if inserted == 1:
            conn.execute(
                "UPDATE accounts SET credits = credits + %s WHERE id = %s",
                (change, payload["clientReference"]),
            )
```

### 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:

```json
{
  "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"
}
```

## Client reference

Canonical address: https://docs.norelio.eu/client-reference

Pass your own account reference at checkout and get it back in every webhook for that order.

If the buyer is already signed in to your service, pass your internal reference for their account at checkout — the client reference — and it comes back in every notification for that order as clientReference. Credit the payment to the right account without matching emails by hand: at checkout the buyer may enter any email.

### In the widget

In the widget, set the attribute on the same element that carries the widget attribute. The script reads it at the moment of the click, not at page load, so you can set it after the user signs in. It works in both widget modes: the button appends it to the payment link, the card passes it into the product panel and from there into the purchase. The attribute is `data-client-reference`:

```html
<div data-lis-widget="wgt_1a2b3c" data-client-reference="acc_10294"></div>
```

### In a direct payment link

In a direct payment link, pass it as a query parameter. Payment links are in the cabinet catalog. The parameter is `client_reference`:

```text
https://pay.lis-system.eu/pay/prod_1a2b3c?client_reference=acc_10294
```

### From your server

When the payment is [created from your server](https://docs.norelio.eu/checkouts), pass the reference in the `clientReference` field of the request body; there it is required.

### Where it comes back

The value arrives in every event of the order: payment, partial and full refund, dispute opened and resolved. Subscription events — renewal, failed charge, cancellation, end — carry the value of the order the subscription started with. If the buyer cancels at the payment provider and retries on our payment page, the new order gets the same value.

The value is returned in the `clientReference` field of every [webhook](https://docs.norelio.eu/webhooks) for the order, and you can look a buyer up by it in the [Customer state](https://docs.norelio.eu/customer-state) and the [Access check](https://docs.norelio.eu/access-check). The partner also sees it in the cabinet's order list and in the CSV export.

### Format

Up to 128 characters: Latin letters, digits and . _ : - only, and the first character may not be "-" or "." (a spreadsheet opening the order export would read "-A1" as a formula). A malformed or missing value does not stop the purchase: the order is created, and clientReference in the notification is null.

> The value is set by the buyer's browser: it is a crediting address, not proof of identity. The notification signature proves that the notification is from us and that the value is exactly what arrived at checkout. The value itself is set by the buyer's browser: anyone can change the attribute on your page or the link parameter. Use it as a crediting address — which account to credit — not as proof of identity or of the right to an account. Do not pass an email, a name, a phone number or other personal data, only your internal account reference. The buyer's email arrives in its own field anyway. If you need protection against tampering, sign the value on your side: for example reference.signature, where signature is an HMAC-SHA256 of the reference with your own key, in hex (64 characters; the base64 characters + / = do not pass the format), and verify that signature when the notification arrives.

## Return URL

Canonical address: https://docs.norelio.eu/return-url

Bring the buyer back to your site after payment, like success_url at payment providers: allowed sites, the attribute, the parameter and what the result page does.

After payment the buyer stays on our result page. To bring them back to your site — like success_url at payment providers — enable the return. Banks and payment providers review the payment domain, so the buyer can be returned only to a site on the list the platform keeps for your account.

### How to enable it

First the platform adds your site to the allowed list: the partner asks support from the cabinet's Messages section (the list is shown in the cabinet's developer documentation and in the widget settings). Then the partner switches on the "return the buyer to the site after payment" option in the widget settings. With it, payment opens in the same tab instead of a new one; otherwise the return would open your site in a second tab. The option concerns only the widget's button and card: a direct payment link with the return_url parameter returns the buyer to any site on the list without it.

### In the widget

The widget takes the return address from the attribute on the same element as the widget attribute. Without it, the address of the page the button was clicked on, with the query string and the fragment removed: they may carry your own tokens. Read at the moment of the click. The address from the attribute must lead to the same site the button stands on; otherwise, as with a malformed address, the button opens payment in a new tab without a return, as if the option were off. The widget receives the return flag from our server only when the page is open on an allowed site: on any other site the same code behaves as before — payment in a new tab, no return. The attribute is `data-return-url`:

```html
<div data-lis-widget="wgt_1a2b3c" data-return-url="https://pawclaw.com/thank-you"></div>
```

### In a direct payment link

In a direct payment link, pass it as a query parameter. The parameter is `return_url`:

```text
https://pay.lis-system.eu/pay/prod_1a2b3c?return_url=https%3A%2F%2Fpawclaw.com%2Fthank-you
```

### Which addresses are accepted

Only an https:// address without credentials, up to 2000 characters, whose host exactly matches one of the allowed sites (or its www. form) is accepted. Subdomains, look-alike names, IP addresses, ports and a trailing dot do not pass. A rejected address does not stop the purchase: the order is created, and the buyer stays on the result page as without a return. The check is repeated when the link is shown, so a site removed from the list after the payment no longer gets the return.

### What the buyer comes back with

On a confirmed payment one query parameter is appended to your address: norelio_payment with the order id, the same paymentId as in the notification. If there is nothing to show the buyer on our side (the product has no delivery, or you deliver it yourself on the notification), the buyer is returned automatically five seconds after the payment is confirmed, with a "Return now" button. If the page has something to deliver — a code, text, a link, a file, or "the seller is preparing your order" for a manual service — there is no automatic return: the buyer collects the purchase and leaves with a "Back to …" button. Until the confirmation arrives the page waits for it for up to two minutes; if it does not arrive, the page shows a return button without the order id and says the confirmation will come by email. On a cancelled, failed or refunded payment: a return button to your address without the order id.

Example return address after a confirmed payment:

```text
https://pawclaw.com/thank-you?norelio_payment=ord_9z8y7x
```

> Credit on the webhook, not on the return. Anyone can open an address with norelio_payment: it is visible in the address bar and in the browser history. It is a convenience for the buyer, not a confirmation of payment. Credit the payment only on a notification with a verified signature (see the webhook events). Use the return page to tell the buyer everything went through and, if you like, to ask your own server whether the notification for this paymentId has already arrived. The buyer leaves our page without a referrer: the receipt key they open the purchase with never reaches you.

## Payment widget and checkout panel

Canonical address: https://docs.norelio.eu/widget

Embed a buy button or a product card on your site: the snippet, the attributes it reads, direct checkout and what your terms must contain, and the direct payment link.

The widget is two lines the partner copies from the cabinet and you paste into your page: a placeholder element and a script tag. The script asks Norelio what to draw, draws a button or a card, and sends the buyer to Norelio's payment pages. No payment form, no card field and no payment-provider button is ever drawn inside your page: the whole payment happens on Norelio's domain, and nothing about the buyer passes through your site.

### The snippet

```html
<div data-lis-widget="wgt_1a2b3c"></div>
<script src="https://app.norelio.eu/widget.js" async></script>
```

The widget id (`wgt_1a2b3c` above) is created by the partner in the cabinet catalog, under the product, as "Site code"; one product can have several widgets, each with its own look and its own list of allowed sites. The script is served from `https://app.norelio.eu/widget.js`. The product name, price, image and the widget's look are read from the cabinet at display time, so the snippet never has to be pasted again when any of them changes; the answer may be cached for up to a minute.

### Two display modes

- Button — One button with the caption and the current price. A click opens Norelio's payment page for the product in a new tab.
- Card — A small card with the product image, name, price and a button. A click opens a panel over your page with the product description, the price and the checkout: the buyer confirms the terms and pays from the panel, in a new tab on Norelio's payment domain. The panel is Norelio's own page in an iframe; your page never receives its markup.

With the [return URL](https://docs.norelio.eu/return-url) enabled and the page open on one of the partner's allowed sites, payment opens in the SAME tab instead, and the buyer comes back to your page afterwards. With direct checkout (below) neither mode opens the panel or the payment page: the buyer goes straight to the payment provider.

### Attributes the script reads

The script reads nothing from your page beyond its own element. All attributes go on that one element:

| Attribute | Required | Meaning |
| --- | --- | --- |
| `data-lis-widget` | yes | The widget id from the cabinet. The script finds its placeholders by this attribute. |
| `data-client-reference` | no | Your reference for the buyer's account, returned in every webhook for the order. Read at the moment of the click. See [Client reference](https://docs.norelio.eu/client-reference). |
| `data-return-url` | no | Where to bring the buyer back after payment; without it, the current page without its query string and fragment. Used only when the return is enabled for this site. See [Return URL](https://docs.norelio.eu/return-url). |
| `data-package`, `data-amount` | no | Top-up products only: the package number or the amount in minor units chosen for the buyer in advance. See [Top-ups](https://docs.norelio.eu/top-ups). |

### Direct checkout

By default the widget shows the Norelio panel before payment: the purchase terms and a consent checkbox (the plain button opens the Norelio payment page with the same checkbox). With direct checkout the button and the card send the buyer straight to the payment provider's page instead, in the same tab, with no panel and no checkbox on our side. The buyer's consent to the purchase terms is then collected by you, on your own site, and Norelio records that it was collected there.

Direct checkout is switched on by the platform: after reviewing your terms, an admin allows it for your account. Then the partner chooses "Straight to the payment page" under "Start of payment" in the widget settings; "Norelio panel" stays the default, and every widget created before direct checkout existed keeps the panel. Until the permission is granted the option is shown disabled; ask support from the cabinet's Messages section. If the permission is withdrawn, every widget of the partner goes back to the panel at once.

Direct checkout works only on the sites in the widget's own list of allowed sites. A widget with an empty list keeps the panel, and so does the same snippet on any other site. Norelio checks the site the browser reports for the request (its Origin) against that list and refuses a request from anywhere else without creating an order. Do not set a no-referrer referrer policy on pages with the button: with it the browser does not report the site, and the request is refused.

On a click the script submits a form to Norelio in the same tab: the widget id, the client reference and the return address, exactly as from the panel. The product, the price and the mode are taken from the widget on our side, never from the form. Norelio creates the order and sends the buyer to the payment provider. On the Stripe page, under the pay button, the buyer sees "By paying you agree to the purchase terms" with a link to the terms page. PayPal shows no such text: there the consent is entirely yours, and our result page links to the terms. After payment the buyer lands on our result page and, with the return enabled, comes back to your site as described in the return URL section. If the purchase cannot start (the product is unavailable, too many attempts), the buyer lands on our payment page with a short explanation.

### What your terms must contain

Before the buyer presses the button, your site must show your terms and obtain the buyer's agreement to them. Your terms must contain the substance of the Norelio purchase terms:

- the item is digital content or a service performed by the seller;
- delivery begins after payment: immediately for digital content such as a code, a file or a link, or when the seller starts carrying out a manual service;
- by completing the purchase the buyer agrees to delivery starting on that basis and acknowledges that the statutory right of withdrawal ends once delivery has begun;
- as a goodwill policy, the buyer may request a full refund within 3 days of purchase by contacting the seller; after that, refund requests are no longer accepted.

The full text of the Norelio purchase terms, version 2026-08-3, published on the payment domain at `https://pay.lis-system.eu/pay/terms`:

> Purchase terms. This purchase is digital content or a service performed by the seller. Delivery of this item begins after payment — immediately, for digital content such as a code, a file or a link, or when the seller starts carrying out the service, for a manual service. By completing the purchase you agree to delivery of this item starting on that basis and acknowledge that your statutory right of withdrawal for this item ends once its delivery has begun. As a goodwill policy, you may request a full refund within 3 days of purchase by contacting the seller at the address shown on this page. After 3 days refund requests are no longer accepted.

For a subscription product the agreement to recurring charges is also yours to collect: state the price, the billing period and that the subscription renews until it is cancelled. Stripe shows the billing period on its own page.

For every such order Norelio stores who collected the consent (you, on your site), the version and the text of the purchase terms, the buyer's IP address and browser from the request, and the date and the author of the platform's permission. This record is what a payment dispute is answered with.

### Direct payment link

Instead of the widget you can link to the payment page directly: `https://pay.lis-system.eu/pay/prod_1a2b3c`. The partner copies the link from the cabinet catalog. Two optional query parameters are read: `client_reference` (the [client reference](https://docs.norelio.eu/client-reference)) and `return_url` (the [return URL](https://docs.norelio.eu/return-url)).

### Allowed sites and availability

- A widget may carry a list of sites it is allowed on, kept by the partner in the cabinet. An empty list means any site. On a site not on the list the visitor sees "The payment button is not enabled on this site." instead of the button.
- A button is drawn only while the product can be bought: it is active, approved, has a price and the partner's payment intake is switched on. Otherwise the visitor sees "This product is currently unavailable for purchase.".
- The script fetches the widget data without cookies, uses no `innerHTML`, and never receives, stores or sends card details, provider credentials or Norelio keys.

## Taxes and payouts

Canonical address: https://docs.norelio.eu/taxes-and-payouts

Who the merchant of record is, how VAT is handled for buyers inside and outside the EU, and how partners are paid.

Norelio is the merchant of record. Every purchase is a sale by Norelio to the buyer, on Norelio's own payment account; the buyer's contract, receipt, refund and dispute are with Norelio, not with the partner. Your application never handles the buyer's payment details.

VAT for buyers in the European Union is calculated and collected automatically at checkout: the company behind Norelio is registered under the EU One-Stop Shop (OSS) scheme, so the buyer pays the VAT rate of their own country and Norelio reports and remits it. Buyers outside the European Union are not charged VAT.

The partner receives a payout from Norelio under the partner's own terms. The amounts, the commission, the hold period and the payout history are shown in the partner's cabinet; this documentation does not repeat them, and the API does not expose them.

> The [webhook payload](https://docs.norelio.eu/webhooks) carries the amount the buyer paid, in the buyer's currency — never the partner's share, the commission or a hold figure.

## Changelog

Canonical address: https://docs.norelio.eu/changelog

The notification payload version and the compatibility commitment.

The notification payload is versioned by its `version` field; the current version is 1. Payload fields are only ever ADDED: we never rename or remove an existing field and never change its meaning. An incompatible change is rare and always increments version. Your code can safely ignore fields it does not know: a new field never requires changing a check you have already written, and a version you have already seen keeps arriving in the same shape.

### Payload version 1

Fields today: `version`, `event`, `test`, `productId`, `customerEmail`, `paymentId`, `amount`, `currency`, `taxAmount`, `occurredAt`, `accessValidUntil`, `clientReference`, `credits`, `creditsUnit`, `refundedAmount`, `eventId`. The version was introduced together with the `version` and `test` fields and the test notification. Added since then without a version change, as the commitment promises for additions, oldest first:

- `accessValidUntil` on subscription events, and the `subscription.payment_failed` and `subscription.canceled` events.
- `clientReference` — the partner's account reference passed at checkout.
- `taxAmount` — VAT charged to an EU buyer on top of `amount` at checkout, in the same currency; `amount` stays the price without VAT, and `taxAmount` is 0 when no VAT applied.
- `credits` and `creditsUnit` — on a top-up order, the number of credits bought and the unit name; `null` on every other order and on subscription events.
- `refundedAmount` — on `order.refunded_partial` and `order.refunded_full`, the amount this refund returned, without VAT, in the units of `amount`; `null` on every other event. `eventId` — our id of the event: the same on every delivery of one event, different for every other event, including two partial refunds of one order. Deduplicate on `eventId`.

The [Webhooks](https://docs.norelio.eu/webhooks) section describes every field as it is today; the [OpenAPI document](https://docs.norelio.eu/openapi.json) is regenerated from the same definitions on every deployment.
