DocsTools & developers

Phone opt-in forms.

Collect a phone number, verify it with an SMS code, and record consent to send marketing SMS. Drop in a ready-made form, or render your own fields and call two endpoints.

The drop-in form

Fastest option, and it stays visually isolated from your page. Add the placeholder where you want the form, and the script once:

<div data-noli-optin
     data-noli-key="pk_live_..."
     data-noli-form="presale"></div>

<script src="https://app.heynoli.ai/widget/v1/noli-optin-form.js" async></script>

The key and the form slug both come from the Noli admin, under Settings → Opt-in forms. The form finishes in place rather than redirecting - it is built for pre-sale capture, where sending the visitor away loses them.

Consent text configured here is what gets stored as proof — show the same wording on your page.

Headless: the flow

Render the fields in your own design and call the endpoints directly. Same key, same limits, nothing extra to enable.

https://qremotwvbhxqkzmxrqzs.supabase.co/functions/v1/optin-form
1. Customer types a phone number in YOUR field
2. POST ?action=request      → we send an SMS, return an otp_id
3. Customer types the code in YOUR field
4. POST ?action=verify       → we check it, save the customer, record consent

Every call carries ?key=<publishable key>&form=<slug>. The code is never returned in the response - it exists only in the SMS. Returning it would let anyone who guesses a phone number sign up as its owner without ever holding the phone.

?action=request

const res = await fetch(`${BASE}?key=${KEY}&form=presale&action=request`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ phone: '+359888123456' }),
});
const { ok, otp_id } = await res.json();

phone accepts E.164 (+359888123456) or Bulgarian local (0888123456).

ResponseMeaning
{ ok: true, otp_id, expires_in_seconds }Sent. Keep otp_id for step 2.
{ ok: true, already_registered: true, message }The number already belongs to a verified, opted-in member. No SMS was sent and nothing was charged. Show message and stop - there is no step 2.
400 invalid_phoneNot a parseable number.
429 rate_limitedToo many codes - see Limits.
502 send_failedThe carrier rejected it.
503 unavailableThe business has no credits left.

?action=verify

const res = await fetch(`${BASE}?key=${KEY}&form=presale&action=verify`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    otp_id,
    code: '123456',
    name: 'Ivan',              // only if the form collects it
    email: 'ivan@example.com', // only if the form collects it
    consent: true,             // required — the customer ticked your box
  }),
});
ResponseMeaning
{ ok: true, message }Verified. message is your configured success text.
400 invalid_codeWrong code - let them retry.
400 code_expiredOlder than 10 minutes. Request a new one.
400 code_usedAlready redeemed.
400 consent_requiredconsent was not true.
429 too_many_attemptsFive wrong tries. Request a new code.

An existing customer with that number is opted in rather than duplicated, and details you send never overwrite ones already on file.

?action=config

Returns the wording and styling configured in the admin, so a headless integration stays in sync with it:

{ "ok": true, "form": { "headline": "…", "consent_text": "…",
  "button_label": "…", "success_message": "…", "accent_color": "#0d9488",
  "collect_name": true, "collect_email": false } }
⚠️

Show `consent_text` next to your checkbox. Noli stores the version configured in the admin as the proof of consent. If your page shows different wording, the record will not match what the customer actually agreed to. Consent text is never taken from the request body - a string the sender controls would be worthless as evidence against that same sender.

Limits

ScopeLimitWhy
Per phone number3 codes / 10 minThe real anti-spam guard.
Per IP address20 codes / hourLoose on purpose - carriers share IPs via CGNAT, and a server-side integration puts every visitor behind one address.
Per business200 codes / hourBackstop if the key leaks.
Verify attempts5 per codeThen request a new one.

The hard ceiling is your credit balance: every code is an SMS charged to the business, so a leaked key can never spend more than the account holds. Each code is billed per segment - 0.6 credits per segment by default, and Cyrillic fits 67 characters per segment, so the code SMS is a single segment unless the business name is very long.

CORS

Browser requests work from any origin, unless the widget key has an origin allowlist configured - then only those origins are accepted. Set one if the form lives on a fixed set of domains.