WebScript (notifications directly from the page)
WebScript is a lightweight way to receive notifications directly from someone else’s/your own
web page without touching the backend. You create a “script” in Notifly,
specify the event type (page open / form submit / button click /
console errors) — and get a ready HTML snippet with a public URL like
/script/T<token>.
You insert it into your site template, a widget, an email, or a README — each time the event fires, a notification arrives in the chosen channel.
Useful for:
- sites on closed-source CMSs where it’s hard to reach the backend;
- landing pages and static forms (Tilda, GitHub Pages, Netlify);
- quick experiments (“I want a ping every time someone presses Buy”);
- widgets in third-party SaaS admin panels where you can only insert
<script>; - replacing Sentry/Bugsnag for pet projects and small teams.
Script types
Section titled “Script types”Four preset templates are supported. You can copy the snippet with ready HTML/JS directly from the admin — here is only a description of behavior.
scriptType | When it triggers | What goes into message |
|---|---|---|
page_open | On page load (<script> executes) | Страница: <title> + URL: <location> |
form_submit | When any <form> on the page is submitted | All non-empty form fields (name: value) |
button_click | On click of an element with the data-notifly attribute | The data-notifly attribute text or the text content + URL |
console_errors | When a JS error occurs in the browser | Stacktrace, URL, User-Agent, lineno/colno |
All three variants send a POST to /script/T<token> with an application/json
body {title, message} — this is a public endpoint that does not require authorization
(authentication is the T<token> in the URL).
Creating a script
Section titled “Creating a script”Through the admin dashboard
Section titled “Through the admin dashboard”- Open app.notifly.ru → Web Scripts.
- Click Create script, fill in:
- Name — for display, e.g. “Landing — click “Buy””.
- Channel — where to send notifications.
- Event type —
Открытие страницы,Отправка формыorКлик на кнопку. - Notification title — default
titleof the message. - Priority — 0 = take the channel’s
defaultPriority, otherwise 1–10.
- After creation click Show snippet — there you will find the ready HTML that
you can copy into the site’s
<head>or anywhere in the<body>.
Via REST API
Section titled “Via REST API”curl -X POST "$NOTIFLY_URL/web-script" \ -H "Content-Type: application/json" \ -H "X-Gotify-Key: <client-token>" \ -d '{ "name": "Лендинг — клик «Купить»", "appId": 12345, "scriptType": "button_click", "title": "Клик «Купить» на лендинге", "priority": 7 }'For console_errors:
curl -X POST "$NOTIFLY_URL/web-script" \ -H "Content-Type: application/json" \ -H "X-Gotify-Key: <client-token>" \ -d '{ "name": "Production — ошибки фронтенда", "appId": 12345, "scriptType": "console_errors", "title": "JS Error", "priority": 8 }'The response will include the script object with a public token (prefix T):
{ "id": 8, "token": "T7c2a8f3b1e0d4a6c8e9f", "appId": 12345, "appName": "Marketing", "name": "Лендинг — клик «Купить»", "scriptType": "button_click", "title": "Клик «Купить» на лендинге", "priority": 7, "created": "2026-04-30T10:11:12Z", "lastUsed": null}The full URL for the trigger is ${NOTIFLY_URL}/script/T7c2a8f3b1e0d4a6c8e9f.
Ready-made snippets
Section titled “Ready-made snippets”All three snippets are variations of the same fetch(url, {method: "POST", ...}).
They never fail on the site side (.catch(function(){})) and do not
require CORS configuration, because /script/:token always responds 200 OK.
page_open — ping on page open
Section titled “page_open — ping on page open”<!-- Notifly — notification on page open --><script>(function() { fetch("https://your-notifly/script/T7c2a8f3b1e0d4a6c8e9f", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ title: "Лендинг — клик «Купить»", message: "Страница: " + document.title + "\nURL: " + location.href }) }).catch(function(){});})();</script>form_submit — ping on form submission
Section titled “form_submit — ping on form submission”The script attaches to all <form> on the page, collects all non-empty
fields and sends them in the notification body:
<script>document.addEventListener("DOMContentLoaded", function() { document.querySelectorAll("form").forEach(function(form) { form.addEventListener("submit", function() { var fd = new FormData(form); var lines = []; fd.forEach(function(v, k) { if (v) lines.push(k + ": " + v); }); fetch("https://your-notifly/script/T...", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ title: "Заявка с сайта", message: lines.join("\n") || "(пустая форма)" }) }).catch(function(){}); }); });});</script>button_click — ping on element click
Section titled “button_click — ping on element click”Triggers only for elements that have a data-notifly attribute:
<script>document.addEventListener("click", function(e) { var el = e.target.closest("[data-notifly]"); if (!el) return; var label = el.getAttribute("data-notifly") || el.textContent.trim() || "кнопка"; fetch("https://your-notifly/script/T...", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ title: "Клик «Купить»", message: "Клик: " + label + "\nСтраница: " + location.href }) }).catch(function(){});});</script><!-- Example usage: --><button data-notifly="Заказать">Заказать</button>console_errors — console error capture (Sentry-lite)
Section titled “console_errors — console error capture (Sentry-lite)”The most powerful script type — a full JS error catcher that works as a lightweight alternative to Sentry / Bugsnag / TrackJS. The script captures:
window.onerror— all uncaught exceptions;unhandledrejection— rejected promises without.catch();console.error()— explicitconsole.errorcalls (optional).
Features:
- Batching — errors accumulate in a queue and are sent in batches (up to 10 items or every 3 seconds).
- Deduplication — the same error (by message + top stack frame) is not sent repeatedly within a single session.
- Request limit — maximum 100 POSTs per page load to avoid infinite loops.
- sendBeacon —
navigator.sendBeaconis used when available (doesn’t block navigation / tab close). - Final flush — on
pagehidethe script sends the remaining queue. - 1 batch = 1 quota event — the whole array of errors becomes one notification with one push.
<!-- Notifly — console error capture in the browser --><script>(function() { var ENDPOINT = "https://your-notifly/script/T..."; var RELEASE = (window.NOTIFLY_RELEASE || ""); var queue = [], timer = null, seen = {}, SENT_CAP = 100, sent = 0;
function fingerprint(e) { return (e.message || "") + "|" + ((e.stack || "").split("\n")[1] || ""); } function flush() { timer = null; if (!queue.length || sent >= SENT_CAP) return; var batch = queue.splice(0, queue.length); sent++; var payload = JSON.stringify({ title: "Console errors", release: RELEASE, errors: batch }); try { if (navigator.sendBeacon) { navigator.sendBeacon(ENDPOINT, new Blob([payload], {type: "application/json"})); return; } } catch (_) {} fetch(ENDPOINT, { method: "POST", headers: {"Content-Type": "application/json"}, body: payload, keepalive: true }).catch(function(){}); } function enqueue(item) { var fp = fingerprint(item); if (seen[fp]) return; seen[fp] = 1; item.url = location.href; item.userAgent = navigator.userAgent; item.ts = Date.now(); queue.push(item); if (queue.length >= 10) { clearTimeout(timer); flush(); return; } if (!timer) timer = setTimeout(flush, 3000); }
window.addEventListener("error", function(ev) { var err = ev.error || {}; enqueue({ type: "error", message: ev.message || String(err.message || err) || "Unknown error", stack: (err && err.stack) || "", lineno: ev.lineno || 0, colno: ev.colno || 0 }); }); window.addEventListener("unhandledrejection", function(ev) { var r = ev.reason; enqueue({ type: "unhandledrejection", message: String(r && (r.message || r.toString()) || "Unhandled promise rejection"), stack: (r && r.stack) || "" }); }); var _ce = console.error; console.error = function() { try { var parts = []; for (var i = 0; i < arguments.length; i++) { var a = arguments[i]; parts.push(a && a.message ? a.message : (typeof a === "string" ? a : JSON.stringify(a))); } enqueue({type: "console.error", message: parts.join(" ").slice(0, 500), stack: ""}); } catch (_) {} return _ce.apply(console, arguments); }; window.addEventListener("pagehide", flush);})();</script>Data format
Section titled “Data format”Request body for POST /script/T<token>:
{ "title": "Console errors", "release": "v1.2.3", "errors": [ { "type": "error", "message": "Cannot read properties of undefined (reading 'map')", "stack": "TypeError: Cannot read properties...\n at App.tsx:42:12\n at ...", "url": "https://example.com/dashboard", "lineno": 42, "colno": 12, "userAgent": "Mozilla/5.0 ...", "ts": 1716556800000 } ]}Notifly server forms one notification from the batch:
- Title — the first error (+
(+N more)if there are multiple errors). - Body — URL, release, User-Agent, and stacktraces (up to 5 errors are shown in full).
Release binding
Section titled “Release binding”Set window.NOTIFLY_RELEASE before loading the script — the value will be
included in the release field of each batch:
<script>window.NOTIFLY_RELEASE = "v2.1.0-abc1234";</script><!-- Notifly console_errors snippet here -->Where to insert in popular frameworks
Section titled “Where to insert in popular frameworks”| Framework | Where to place |
|---|---|
| Next.js (App Router) | app/layout.tsx in <head> via next/script strategy="beforeInteractive" |
| Next.js (Pages) | pages/_document.tsx in <Head> |
| React (CRA / Vite) | public/index.html in <head> before the bundle |
| Vue / Nuxt | nuxt.config → app.head.script or app.html |
| Angular | src/index.html in <head> before polyfills |
| Astro | src/layouts/Layout.astro in <head> |
| Electron | In the renderer HTML <head> |
Source maps — expanding minified stacks
Section titled “Source maps — expanding minified stacks”If your JS went through a minifier (esbuild, terser, Webpack production), stacktraces
in the browser look like at f (https://app.example.com/static/js/main.abc123.js:1:12345) —
without function names and real line numbers. Notifly can on-the-fly resolve such frames
back to original sources (TypeScript / .vue / .jsx) using uploaded source maps.
How it works
Section titled “How it works”- Build the frontend with
.mapfiles enabled (sourceMap: true). - Upload these
.mapfiles to Notifly via the REST API — one map per.jsfile. - Specify the same
releasefor each.mapthat you pass towindow.NOTIFLY_RELEASE. - When an error is received Notifly finds the
.mapbyrelease+ URL prefix + file name and replacesURL:LINE:COLwithsrc/file.ts:LINE:COL— directly in the notification text.
Resolving is best-effort: if a map is not found or a frame doesn’t match, the original (minified) stack will still appear in the notification.
Upload via REST
Section titled “Upload via REST”# basic authentication — same login/password as for the admincurl -u admin:admin \ -F "release=v2.1.0-abc1234" \ -F "urlPrefix=https://app.example.com/static/js/" \ -F "fileName=main.abc123.js" \ -F "file=@dist/static/js/main.abc123.js.map" \ "$NOTIFLY_URL/web-script/<scriptId>/sourcemaps"The request body is multipart/form-data with required fields:
| Field | Description |
|---|---|
file | The .map file itself (JSON Source Map v3, up to 10 MB) |
release | Release version — must match window.NOTIFLY_RELEASE |
urlPrefix | The URL prefix from which the browser loads this .js (e.g. https://x/static/) |
fileName | The .js file name without prefix (main.abc123.js), without / or \ |
When a frame matches urlPrefix + fileName Notifly resolves the position. Frame search
is by the longest prefix, so you can keep maps for different domains/CDNs simultaneously.
CLI helper
Section titled “CLI helper”To avoid calling curl in a loop, use the ready script
scripts/upload-sourcemaps.py:
python3 scripts/upload-sourcemaps.py \ --api https://api.notifly.ru \ --user "$NOTIFLY_USER" --password "$NOTIFLY_PASS" \ --script-id 12345 \ --release "$GITHUB_SHA" \ --url-prefix "https://app.example.com/static/js/" \ --dir ./dist/static/jsExample GitHub Actions — uploading maps after build:
- name: Build run: npm run build
- name: Upload sourcemaps to Notifly env: NOTIFLY_USER: ${{ secrets.NOTIFLY_USER }} NOTIFLY_PASSWORD: ${{ secrets.NOTIFLY_PASSWORD }} run: | python3 scripts/upload-sourcemaps.py \ --script-id ${{ vars.NOTIFLY_SCRIPT_ID }} \ --release "${{ github.sha }}" \ --url-prefix "https://app.example.com/static/js/" \ --dir ./dist/static/jsManagement and REST
Section titled “Management and REST”| Method and path | Purpose |
|---|---|
GET /web-script/:id/sourcemaps | list of uploaded maps |
POST /web-script/:id/sourcemaps | upload a .map (multipart) |
DELETE /web-script/:id/sourcemaps/:smid | delete a map (S3 + DB) |
In the admin the Source maps button appears on the script card for
the console_errors type — there you can view the list, delete obsolete maps
or upload a new .map without using the CLI.
Security and limits
Section titled “Security and limits”- Maps are stored in a private S3 bucket on Yandex Object Storage, they don’t have a public URL.
- Contents are deduplicated by
sha256— reuploading the same file does not create extra copies. - Maximum size of a single
.mapis 10 MB. - When a web script is deleted all associated
.mapfiles are removed automatically (cascade). - Resolving is limited by a 3-second timeout per request — if there are many maps, extras are ignored and the original stack will still be included in the notification.
Triggering manually
Section titled “Triggering manually”You can call /script/T<token> with anything — this endpoint is public
and accepts GET and POST. A JSON body is optional: if it’s missing, the push
will use the script’s configured title and the text “(web script trigger)”.
# minimalcurl -fsS "$NOTIFLY_URL/script/T7c2a8f3b1e0d4a6c8e9f" -o /dev/null
# overriding title and textcurl -X POST "$NOTIFLY_URL/script/T7c2a8f3b1e0d4a6c8e9f" \ -H "Content-Type: application/json" \ -d '{"title":"Заявка №42","message":"Имя: Иван\nТелефон: +7..."}'REST API
Section titled “REST API”| Method and path | Authorization | Purpose |
|---|---|---|
GET /web-script | client-token | list of scripts |
POST /web-script | client-token (write) | create |
PUT /web-script/:id | client-token (write) | update |
DELETE /web-script/:id | client-token (write) | delete |
GET /web-script/:id/sourcemaps | client-token (write) | list source maps |
POST /web-script/:id/sourcemaps | client-token (write) | upload a .map |
DELETE /web-script/:id/sourcemaps/:smid | client-token (write) | delete a .map |
GET/POST /script/:token | public | trigger from browser or script |
Security
Section titled “Security”T<token>is generated by a cryptographically secure RNG (160 bits) — including it in a URL is as safe as you are careful not to publish the token in a public repository.- If you accidentally committed
T<token>to a public repo — delete the script via the admin orDELETE /web-script/:id. The old URL will start “eating” requests (quiet200 OK) without creating notifications. - On each successful trigger the
lastUsedfield is updated — in the admin you can see if a script is “hanging” unused.