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. |
{
"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.
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.{
"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, which describes the same payload under webhooks. See also the 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>).
- Build the string {id}.{timestamp}.{raw request body}.
- Compute an HMAC-SHA256 of it with your secret and encode the result in base64.
- Compare the result with the part after the comma in the signature header.
- 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.
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)
);
}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.
