DocsStore platforms

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

KeyWhere you get itWhere it goes
Widget key pk_live_…Settings → Embed on any sitePage source, on the script tag. Public.
Signing secretSettings → Embed on any siteYour server only - signs the identity token.
Secret API key sk_live_…Settings → API & SecurityYour server only - the order call.
Public API key pk_live_…Settings → API & SecurityBrowser 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:

FieldRequiredNotes
api_keyyesThe secret key sk_live_….
order_idyesYour order number. One order earns once - resending it is safe.
amountyesWhat the customer paid, in your store currency. Your earn rules apply to it.
customer_email / customer_phoneone of themHow Noli finds the customer - phone first, then email. Phone in E.164 when you can.
order_statusnopaid (default), fulfilled, completed → earn · pending → recorded, no points yet · cancelled, refunded → points taken back.
order_itemsnoproduct_name, quantity, unit_price, and product_sku / product_id. Feeds recommendations and analytics.
discount_codesnoCodes 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 send pending at placement and completed on delivery.
  • Any status other than pending, cancelled or refunded earns 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 key

3. 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

  1. Send a test order with your own email (cURL below) - the response has points_added or registration_required.
  2. Sign in to the widget with that email - the balance is there.
  3. Send the same order with refunded - points_reversed comes back and the balance drops.
  4. Logged in on your site, open a page - the widget opens already signed in.
  5. 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.