Any website: developer guide.
Everything the developer of a custom site does to connect it to Noli: the widget script, sending orders from the backend, refunds, seamless login, reward codes, CSP and a final test.
Това ръководство на български →
Keys - which one goes where
| Key | Where you get it | Where it goes |
|---|---|---|
Widget key pk_live_… | Settings → Embed on any site | Page source, on the script tag. Public. |
| Signing secret | Settings → Embed on any site | Your server only - signs the identity token. |
Secret API key sk_live_… | Settings → API & Security | Your server only - the order call. |
Public API key pk_live_… | Settings → API & Security | Browser calls only; their points wait for confirmation. Avoid for orders. |
The widget key and the public API key both start with pk_live_ but are different keys - the widget needs the one from Embed on any site.
1. Widget script
Once per page, before </body>, on one line:
<script async src="https://app.heynoli.ai/widget/v1/noli-widget.js" data-noli-key="pk_live_…"></script>
Optional data-noli-* attributes and where each platform takes the tag are listed on Any website. Add the site's domains to the key's allowed origins once you're live.
2. Send orders from your backend
POST https://qremotwvbhxqkzmxrqzs.supabase.co/functions/v1/process-order-v2 with a JSON body:
| Field | Required | Notes |
|---|---|---|
api_key | yes | The secret key sk_live_…. |
order_id | yes | Your order number. One order earns once - resending it is safe. |
amount | yes | What the customer paid, in your store currency. Your earn rules apply to it. |
customer_email / customer_phone | one of them | How Noli finds the customer - phone first, then email. Phone in E.164 when you can. |
order_status | no | paid (default), fulfilled, completed → earn · pending → recorded, no points yet · cancelled, refunded → points taken back. |
order_items | no | product_name, quantity, unit_price, and product_sku / product_id. Feeds recommendations and analytics. |
discount_codes | no | Codes used on the order; reward codes among them are marked as used. |
When to call
- When payment is captured - card, bank transfer once it lands.
- Cash on delivery: when the parcel is delivered and paid (
completed), not when the order is placed - or sendpendingat placement andcompletedon delivery. - Any status other than
pending,cancelledorrefundedearns points, so don't send unpaid orders with another status. - Retry on a network error or a 5xx - the call is idempotent on
order_id.
// Node 18+ — call when the order is paid (or delivered, for cash on delivery)
await fetch('https://qremotwvbhxqkzmxrqzs.supabase.co/functions/v1/process-order-v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: process.env.NOLI_SECRET_KEY, // sk_live_…
order_id: String(order.id), // your order number
amount: order.total, // what the customer paid
order_status: 'paid', // paid | fulfilled | completed | pending | cancelled | refunded
customer_email: order.email,
customer_phone: order.phone, // E.164 when you can, e.g. +359888123456
order_items: order.lines.map((l) => ({
product_sku: l.sku, product_id: String(l.productId),
product_name: l.name, quantity: l.qty, unit_price: l.price,
})),
discount_codes: order.couponCodes, // reward codes used on this order
}),
});<?php
// PHP 7.4+ — call when the order is paid (or delivered, for cash on delivery)
$payload = [
'api_key' => getenv('NOLI_SECRET_KEY'), // sk_live_…
'order_id' => (string) $order['id'],
'amount' => (float) $order['total'],
'order_status' => 'paid',
'customer_email' => $order['email'],
'customer_phone' => $order['phone'],
'order_items' => array_map(fn($l) => [
'product_sku' => $l['sku'], 'product_id' => (string) $l['product_id'],
'product_name' => $l['name'], 'quantity' => $l['qty'], 'unit_price' => (float) $l['price'],
], $order['lines']),
'discount_codes' => $order['coupon_codes'],
];
$ch = curl_init('https://qremotwvbhxqkzmxrqzs.supabase.co/functions/v1/process-order-v2');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$response = json_decode(curl_exec($ch), true);Responses
// Points awarded
{ "success": true, "points_added": 89, "message": "Successfully added 89 points for order 10045", … }
// Customer hasn't joined yet — held, awarded on sign-up
{ "success": true, "registration_required": true, "pending_transaction_id": "…", … }
// Same order sent again — nothing changes
{ "success": true, "already_processed": true, "points_added": 89, … }
// Canceled / refunded
{ "success": true, "order_status": "refunded", "points_reversed": 89 }
// Errors: 400 missing fields · 401 invalid API key · 403 API disabled,
// domain not allowed (public key) or cancellation without the secret key3. Cancellations and refunds
Send the same call with order_status cancelled or refunded. It needs the secret key. Noli takes back all the points the order earned, or voids them if the customer hadn't joined yet.
{
"api_key": "sk_live_…",
"order_id": "10045",
"amount": 89.90,
"order_status": "refunded",
"customer_email": "maria@example.com"
}This call reverses the whole order. For a partial refund, send nothing until the order is fully refunded, or ask us about partial reversals for your setup.
4. Seamless login
For a logged-in customer, sign an HS256 JWT on every page render with the signing secret, and pass it to the widget. Claims:
email- required; Noli finds or creates the member by it.name- optional; used for a new member.exp- required, Unix seconds; keep it a few minutes ahead (we use 5).
<?php
// Sign a logged-in customer for the Noli widget. Runs on YOUR server.
function noli_identity_token(string $email, ?string $name, string $secret): string {
$b64 = fn(string $s) => rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
$header = $b64(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = $b64(json_encode([
'email' => strtolower(trim($email)),
'name' => $name,
'iat' => time(),
'exp' => time() + 300, // 5 minutes
]));
$sig = $b64(hash_hmac('sha256', "$header.$payload", $secret, true));
return "$header.$payload.$sig";
}
// In the page template, for logged-in customers only:
// <script async src="https://app.heynoli.ai/widget/v1/noli-widget.js" data-noli-key="pk_live_…"
// data-noli-identity="<?= htmlspecialchars(noli_identity_token($email, $name, getenv('NOLI_SIGNING_SECRET'))) ?>"></script>// Node — the same token with the built-in crypto module
import { createHmac } from 'node:crypto';
const b64 = (b) => Buffer.from(b).toString('base64url');
export function noliIdentityToken(email, name, secret) {
const now = Math.floor(Date.now() / 1000);
const header = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = b64(JSON.stringify({ email: email.trim().toLowerCase(), name, iat: now, exp: now + 300 }));
const sig = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
return `${header}.${payload}.${sig}`;
}Or set it in NoliConfig before the loader, e.g. in a single-page app:
<script>window.NoliConfig = { identityToken: "<signed token>" };</script>
<script async src="https://app.heynoli.ai/widget/v1/noli-widget.js" data-noli-key="pk_live_…"></script>Guests get no token and sign in with a code as usual. A bad or expired token falls back to guest mode silently.
Never put the signing secret in the page. Only the signed token goes there. If it leaks, rotate it on the Embed page and redeploy.
5. Reward codes
- Fixed code - the merchant enters, per reward, a code that already exists in your shop's discount system. Nothing to build.
- Unique code per customer - expose an endpoint that creates a code in your shop; Noli calls it on every redemption. The request and response are in the REST API reference (Promo code generator); write to support@heynoli.ai to switch it on.
6. Content Security Policy
If the site sends a CSP header, add these to it (product pictures in the chat come from your own image host):
script-src https://app.heynoli.ai style-src https://app.heynoli.ai 'unsafe-inline' connect-src https://qremotwvbhxqkzmxrqzs.supabase.co https://app.heynoli.ai img-src https://qremotwvbhxqkzmxrqzs.supabase.co data: <your product image host>
7. Final test
- Send a test order with your own email (cURL below) - the response has
points_addedorregistration_required. - Sign in to the widget with that email - the balance is there.
- Send the same order with
refunded-points_reversedcomes back and the balance drops. - Logged in on your site, open a page - the widget opens already signed in.
- Redeem a reward - the code works in your cart.
curl -X POST https://qremotwvbhxqkzmxrqzs.supabase.co/functions/v1/process-order-v2 \
-H "Content-Type: application/json" \
-d '{"api_key":"sk_live_…","order_id":"TEST-001","amount":49.99,"order_status":"paid","customer_email":"you@example.com"}'Failed calls, with the reason, are listed under Settings → API & Security.