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.
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-form1. 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).
| Response | Meaning |
|---|---|
{ 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_phone | Not a parseable number. |
429 rate_limited | Too many codes - see Limits. |
502 send_failed | The carrier rejected it. |
503 unavailable | The 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
}),
});| Response | Meaning |
|---|---|
{ ok: true, message } | Verified. message is your configured success text. |
400 invalid_code | Wrong code - let them retry. |
400 code_expired | Older than 10 minutes. Request a new one. |
400 code_used | Already redeemed. |
400 consent_required | consent was not true. |
429 too_many_attempts | Five 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
| Scope | Limit | Why |
|---|---|---|
| Per phone number | 3 codes / 10 min | The real anti-spam guard. |
| Per IP address | 20 codes / hour | Loose on purpose - carriers share IPs via CGNAT, and a server-side integration puts every visitor behind one address. |
| Per business | 200 codes / hour | Backstop if the key leaks. |
| Verify attempts | 5 per code | Then 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.