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.
1. Start in test mode
Issue the TEST key first: in the cabinet under API and webhooks → API key, the "Test key" panel; it starts with nrl_test_ and is shown once. Every step below works with it unchanged, only without money: the payment address leads to a Norelio test page with Pay and Decline buttons instead of a payment provider, the webhooks arrive with test: true and an eventId starting with test_, the customer state and the access check answer about test orders only, and refunds, disputes and subscription renewals are triggered by you — from the cabinet tab "Test orders" or with /api/v1/test/orders/{id}/{action}. Test orders never reach the partner's money figures and are deleted after 30 days. When everything works, replace the value with the live key (next step); nothing else changes.
# Test key first (nrl_test_…): cabinet → API and webhooks → API key → "Test key". Same variable, swapped for the live key later.
NORELIO_API_KEY=...In full: Test mode.
2. Put the API key on your server
The partner issues the live API key in the cabinet under API and webhooks → API key; it starts with nrl_live_ and 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 — in place of the test key once the integration works in test mode. Every call sends it as Authorization: Bearer. One small helper makes the calls; the next steps add functions to it.
# API key: cabinet → API and webhooks → API key. Server environment only.
NORELIO_API_KEY=...// 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;
}3. 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).
# Product id from the cabinet catalog.
NORELIO_PRODUCT_ID=...4. 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.
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;
test: boolean;
};
export function createCheckout(body: CheckoutRequest) {
return norelio<Checkout>("/api/v1/checkouts", { method: "POST", body: JSON.stringify(body) });
}In full: Create a payment from your server, Client reference.
5. 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.
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);
}// 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>
);
}In full: Return URL.
6. 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.
# Signing secret: cabinet → API and webhooks → Webhooks.
NORELIO_WEBHOOK_SECRET=...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);
}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 });
}In full: Webhooks.
7. 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.
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}`);
}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>
);
}In full: Customer state, Access check.
8. 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.
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();
}
}In full: Top-ups.
Next
Next: in test mode, create a payment, press Pay on the test page and watch the webhook arrive; trigger a refund and, for a subscription, a renewal from the cabinet tab "Test orders". Then swap in the live key, make one real purchase and find the order in the partner's cabinet. The rest of this documentation describes every field and every rule.
