WebScript (notifications directly from the page)
WebScript is a lightweight way to receive notifications directly from someone else’s/your
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>.
Insert it into your site template, a widget, an email, or a README — every time the event fires, a notification arrives in the selected channel.
Useful for:
- sites on “boxed” CMS 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 clicks 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 predefined templates are supported. You can copy the snippet with ready HTML/JS directly from the admin — here is only a description of the behavior.
scriptType | When it fires | What goes into message |
|---|---|---|
page_open | On page load (<script> is executed) | Page: <title> + URL: <location> |
form_submit | On submit of any <form> on the page | 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 | An error card: stacktrace, URL, browser, breadcrumbs |
The trigger snippets (page_open, form_submit, button_click) POST to
/script/T<token> with an application/json body {title, message},
console_errors sends a batch of errors (format below). This is a public
endpoint that requires no auth (authentication is the T<token> in the URL
itself).
Every notification created is one event of the daily quota. It is an ordinary channel message: escalation, quiet hours and the channel’s maintenance windows apply to it.
Creating a script
Section titled “Creating a script”Via the admin
Section titled “Via the admin”- Open app.notifly.ru → Integrations → Website scripts.
- Click “Create script”, fill in:
- Name — shown label, e.g. “Landing — Buy click”.
- Channel — where to send notifications (only your own channel).
- Script type — “Page open”, “Form submit”, “Button click” or “Console errors”. The type cannot be changed after creation.
- Notification title — default
titleof the message. - Priority (0-10) — 0 = use the channel’s
defaultPriority. - For “Console errors” — also Notify threshold and Environments (see below).
- The script card shows ready code in tabs: HTML embed, SDK in code
(console errors only), Google Tag Manager and For LLM. The HTML embed
can be copied into the site’s
<head>or any place in<body>.
Via the REST API
Section titled “Via the REST API”curl -X POST "$NOTIFLY_URL/web-script" \ -H "Content-Type: application/json" \ -H "X-Notifly-Key: <api-token>" \ -d '{ "name": "Landing — Buy click", "appId": 12345, "scriptType": "button_click", "title": "Buy click on the landing", "priority": 3 }'For console_errors:
curl -X POST "$NOTIFLY_URL/web-script" \ -H "Content-Type: application/json" \ -H "X-Notifly-Key: <api-token>" \ -d '{ "name": "Production — frontend errors", "appId": 12345, "scriptType": "console_errors", "title": "JS Error", "priority": 8, "notifyThreshold": "error", "environments": "production" }'The response will include the script object with a public token (prefix T):
{ "id": 8, "token": "T7c2a8f3b1e0d4a6c8e9f", "appId": 12345, "appName": "Marketing", "name": "Landing — Buy click", "scriptType": "button_click", "title": "Buy click on the landing", "priority": 3, "notifyThreshold": "", "environments": "", "created": "2026-04-30T10:11:12Z", "lastUsed": null}The full trigger URL is ${NOTIFLY_URL}/script/T7c2a8f3b1e0d4a6c8e9f (the
examples use a shortened token; a real one is T plus 40 hex characters).
Required fields are name (up to 200 characters), appId and scriptType;
priority is 0–10. PUT /web-script/:id takes the same body, and its
scriptType must match the current one. An account can have up to 500 scripts.
Ready snippets
Section titled “Ready snippets”All three trigger snippets are variations of the same
fetch(url, {method: "POST", ...}). They never break the page
(.catch(function(){})) and do not require CORS configuration:
/script/:token answers with permissive CORS headers for any domain.
page_open — ping on page open
Section titled “page_open — ping on page open”<!-- Notifly — notification on page open --><script> (function () { fetch('https://api.notifly.ru/script/T7c2a8f3b1e0d4a6c8e9f', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ title: 'Landing — Buy click', message: 'Page: ' + document.title + '\n\nURL: ' + location.href, }), }).catch(function () {}); })();</script>form_submit — ping on form submit
Section titled “form_submit — ping on form submit”The script attaches to all <form> elements 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://api.notifly.ru/script/T...', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ title: 'Website request', message: lines.join('\n\n') || '(empty form)', }), }).catch(function () {}); }); }); });</script>button_click — ping on element click
Section titled “button_click — ping on element click”Fires only for elements that have the 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() || 'button'; fetch('https://api.notifly.ru/script/T...', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ title: 'Buy click', message: 'Click: ' + label + '\n\nPage: ' + location.href, }), }).catch(function () {}); });</script><!-- Usage example: --><button data-notifly="Order">Order</button>console_errors — capture console errors (Sentry-lite)
Section titled “console_errors — capture console errors (Sentry-lite)”The most powerful script type — a full JS error catcher working as
a lightweight alternative to Sentry / Bugsnag / TrackJS. It uses the hosted SDK
(error-sdk.js) served from the Notifly CDN — a ready-made snippet with your token
is generated in the admin panel on the script card. The SDK captures:
window.onerror— all uncaught exceptions (including resource-load errors);unhandledrejection— rejected promises without.catch();console.error(...)— turned into a full event, even without athrow;- CSP violations (
securitypolicyviolation) — as awarning-level event; - manual calls —
ErrorSDK.captureException(err, ctx)andErrorSDK.captureMessage(msg, level, ctx).
Features:
- Breadcrumbs — the SDK buffers
console.error/warn/log/infocalls,fetch/XHR, clicks and SPA navigation; the latest crumbs are attached to the error (“what happened before it”). - Batching — errors are queued and sent in batches (up to 10 items or every 3 s).
- Client-side deduplication — the same error is not re-sent within a ~10 s window.
- Server-side deduplication — a notification for the same issue arrives at most once every 2 minutes; repeats within that window only increase the counter.
- Sampling —
sampleRate(1 = everything) thins sending on the client; the server extrapolates counters. - Request limit — at most 100 POSTs per page load, to avoid infinite loops.
- sendBeacon —
navigator.sendBeaconis used when leaving the page (does not block navigation). - Final flush — on
pagehide/ tab hidden the script sends the remaining queue. - Sanitization — secrets (password/token/cookie/…) and sensitive query parameters are stripped both on the client and again on the server.
- 1 batch = at most one notification and one quota event. If there is nothing to
notify about (everything is a repeat within the deduplication window, below the
threshold or from a foreign environment), the server answers
{"ok": true, "deduped": true}and no quota is spent.
<!-- Notifly — browser error capture (hosted SDK) --><script src="https://app.notifly.ru/static/error-sdk.js" crossorigin="anonymous"></script><script> // window.NOTIFLY_RELEASE can be set BEFORE this script to tag events with a release. window.ErrorSDK && window.ErrorSDK.init({ endpoint: 'https://api.notifly.ru/script/T...', app: 'My App', release: window.NOTIFLY_RELEASE || '', environment: 'production', sampleRate: 1, maxBreadcrumbs: 20, });</script>init() parameters:
| Parameter | Description |
|---|---|
endpoint | Public script URL POST /script/T<token> (required) |
app | Application name — goes into the batch’s title field (the notification title is built from the first error) |
release | Release tag (git sha / version) — needed for source maps |
environment | Environment (production / staging / …, production by default) — checked against the script’s allowlist |
sampleRate | Share of errors to send, 1 = all |
notifyThreshold | Client-side hint for the notification threshold (the server decides by the script setting) |
maxBreadcrumbs | How many “crumbs” to attach to an error (20 by default; the server rejects more than 30) |
ignoreErrors | Do not send errors whose message matches (string — substring, RegExp — test) |
denyUrls / allowUrls | Filter by the URL the error comes from (the top stack frame) |
beforeSend | function (event) { … } — the last hook before sending; null = do not send |
Events filtered on the client never reach the server — they spend no quota and do not show up in issues or charts.
Data format
Section titled “Data format”The SDK builds the batch itself; below is the schema for custom integrations. Request body
of POST /script/T<token> (max 64 KiB, up to 50 errors per batch):
{ "title": "My App", "release": "v1.2.3", "environment": "production", "sampleRate": 1, "flushReason": "batch", "errors": [ { "eventId": "3f2a…", "type": "error", "level": "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, "sentAt": "2026-07-14T12:00:00.000Z", "exception": {"name": "TypeError", "message": "...", "stack": "..."}, "runtime": { "url": "https://example.com/dashboard", "path": "/dashboard", "referrer": "https://example.com/", "userAgent": "Mozilla/5.0 ...", "language": "en-US", "viewport": "1920x1080" }, "context": {"feature": "checkout"}, "breadcrumbs": [ { "category": "ui.click", "level": "info", "message": "", "data": {"target": "button#pay"}, "timestamp": "2026-07-14T11:59:58.000Z" } ] } ]}Required fields of each error: message, type (error | unhandledrejection |
console.error | exception | message), level (info | warning | error |
fatal), runtime.url and sentAt (RFC3339). A batch without environment, with an
unknown type/level, with more than 50 errors or more than 30 breadcrumbs per error
is rejected with 400; a body over 64 KiB gets 413. The server re-sanitizes
everything it receives: sensitive keys and query parameters, JWT/Bearer tokens,
emails, phone numbers, card numbers and public IPs are replaced with [Filtered].
Notifly server forms one Sentry-style notification card from the batch:
- Title — the first error (
TypeError: …, +(+N more)if several errors). - Body (Markdown) — tags (level · environment · release · browser · language), URL:line:col, referrer/viewport, the first error’s stacktrace (already resolved via source maps), a compact list of the rest (up to 4), the last breadcrumbs (up to 3), the issue’s occurrence count and when it was first seen.
Notification threshold and environments
Section titled “Notification threshold and environments”A console_errors script has two server-side settings (in the admin UI — the
“Notify threshold” and “Environments” fields, in the API —
notifyThreshold and environments):
notifyThreshold—info,warning,errororfatal: notify only about errors of this level and above. Empty — any level. Errors below the threshold are still grouped into issues and shown on the chart, just without a push.environments— a comma-separated allowlist of environments, e.g.production,staging. Empty — any environment. A batch from an environment not on the list is dropped entirely: it reaches neither issues nor charts.
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 — unfolding minified stack
Section titled “Source maps — unfolding minified stack”If your JS was minified (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 lines. Notifly can on-the-fly map such frames
back to sources (TypeScript / .vue / .jsx) using uploaded source maps.
How it works
Section titled “How it works”- Build your frontend with
.mapfiles enabled (sourceMap: true). - Upload these
.mapfiles to Notifly via the REST API — one map per.jsfile. - Give each
.mapthe samereleaseyou pass inwindow.NOTIFLY_RELEASE. - When an error arrives, Notifly finds the
.mapbyrelease+ URL prefix + file name and replacesURL:LINE:COLwithsrc/file.ts:LINE:COL— right in the notification text.
Resolving is best-effort: if the map is not found or the frame does not match, the notification gets the original (minified) stack.
Upload via REST
Section titled “Upload via REST”# API token with write accesscurl -H "X-Notifly-Key: <api-token>" \ -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"Instead of a token this endpoint — the only one in the API — also accepts the
account’s login and password (HTTP Basic, curl -u "$NOTIFLY_USER:$NOTIFLY_PASSWORD"):
handy for older CI scripts. Source maps are accepted only for console_errors
scripts.
The request body is multipart/form-data with the 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 | URL prefix the browser loads this .js from (e.g. https://x/static/) |
fileName | Name of the .js file without the prefix (main.abc123.js), no / and \ |
When a frame matches urlPrefix + fileName, Notifly resolves the position. Frame
lookup uses the longest prefix, so you can keep maps for several domains / CDNs at
the same time. The file is validated on upload: not a Source Map v3 — 400, over
10 MB — 413; the anti-XSSI prefix )]}' is stripped automatically.
From an SDK
Section titled “From an SDK”If your build already talks to Notifly from code, the upload is one SDK call with an API token (write access), no basic auth:
f, _ := os.Open("dist/static/js/main.abc123.js.map")defer f.Close()
_, err := admin.UploadSourceMap(ctx, scriptID, notifly.SourceMapUpload{ Release: os.Getenv("GITHUB_SHA"), // must match window.NOTIFLY_RELEASE URLPrefix: "https://app.example.com/static/js/", FileName: "main.abc123.js", Map: f,})with open("dist/static/js/main.abc123.js.map", "rb") as f: admin.upload_source_map(script_id, os.environ["GITHUB_SHA"], "https://app.example.com/static/js/", "main.abc123.js", f)Re-uploading the same (release, fileName) pair replaces the map rather than
creating duplicates, so re-running the build is safe. The SDK checks the file size
and name before sending.
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 \ --token "$NOTIFLY_TOKEN" \ --script-id 12345 \ --release "$GITHUB_SHA" \ --url-prefix "https://app.example.com/static/js/" \ --dir ./dist/static/jsThe script takes an API token from --token / NOTIFLY_TOKEN, or a login and
password from --user/--password (NOTIFLY_USER/NOTIFLY_PASSWORD). A GitHub
Actions example — uploading maps after the build:
- name: Build run: npm run build
- name: Upload sourcemaps to Notifly env: NOTIFLY_TOKEN: ${{ secrets.NOTIFLY_TOKEN }} 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 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 a console_errors script card —
there you can view the list, delete outdated maps or upload a new .map without the CLI.
Security and limits
Section titled “Security and limits”- Maps are stored in a private S3 bucket (Yandex Object Storage), they have no direct public URL.
- Content is deduplicated by
sha256— re-uploading the same file won’t create duplicates. - Maximum size of a single
.mapis 10 MB, at most 500 maps per script. - Deleting a web script removes all related
.mapfiles automatically (cascade). - Resolving is limited by a 3-second timeout per request — if there are many maps, extras are ignored and the original stack still appears in the notification.
Triggering manually
Section titled “Triggering manually”You can hit /script/T<token> with anything — this endpoint is public
and accepts GET and POST. A JSON body is optional: without it the push
gets the title from the script settings and the text “(web script trigger)”.
# minimalcurl -fsS "$NOTIFLY_URL/script/T7c2a8f3b1e0d4a6c8e9f" -o /dev/null
# override title and messagecurl -X POST "$NOTIFLY_URL/script/T7c2a8f3b1e0d4a6c8e9f" \ -H "Content-Type: application/json" \ -d '{"title":"Request #42","message":"Name: John\nPhone: +1..."}'Responses: 200 {"ok": true} — the notification is created; 400 — unknown token
or invalid body; 413 — body over 64 KiB; 429 — the daily quota is exhausted or
too many requests; 503 — a temporary failure, the request can be retried.
REST API
Section titled “REST API”| Method & path | Authorization | Purpose |
|---|---|---|
GET /web-script | API token | list scripts |
POST /web-script | API token (write) | create |
PUT /web-script/:id | API token (write) | update |
DELETE /web-script/:id | API token (write) | delete |
GET /web-script/:id/sourcemaps | API token | list source maps |
POST /web-script/:id/sourcemaps | API token (write) or Basic | upload a .map |
DELETE /web-script/:id/sourcemaps/:smid | API token (write) | delete a .map |
GET /web-script/:id/issues | API token | list of aggregated errors |
GET /web-script/:id/issue-count | API token | number of unresolved issues ({"unresolved": N}) |
POST /web-script/:id/issues/:iid/resolve | API token (write) | mark as resolved |
POST /web-script/:id/issues/:iid/ignore | API token (write) | mute the issue |
DELETE /web-script/:id/issues/:iid | API token (write) | delete |
GET /web-script/:id/issues/:iid/breadcrumbs | API token | the user’s last actions before the error |
POST /web-script/:id/issues/:iid/analyze | API token | AI root-cause analysis (body: lang, force) |
GET /web-script/:id/issues/:iid/analysis | API token | the saved analysis (204 if there is none yet) |
GET /web-script/:id/series | API token | time series of script triggers |
GET /web-script/:id/error-series | API token | time series of console_errors errors |
GET /web-script/:id/flush-stats | API token | distribution of batch send reasons |
GET/POST /script/:token | public | trigger from a browser or a script |
Channel tokens (including shared ones) are not accepted by the management
endpoints — 403.
The “Issues” section on a console_errors script card groups incoming errors
into deduplicated issues. The grouping key is the error type, up to five top stack
frames (after source maps; line numbers without columns, URLs without the query and
with ids in the path generalized) and the release; when there is no recognizable
stack — the normalized message (numbers, UUIDs, URLs replaced with placeholders) and
the page URL. For each issue you see the occurrence count (and an estimate adjusted
for sampleRate — estimatedCount), first and last seen times, a sample stack, URL
and browser; “Events before the error” (breadcrumbs) are kept for ~14 days, and the
issues themselves are deleted after 90 days without new events.
- Mark as resolved (
/issues/:iid/resolve) — the next occurrence notifies again as a regression. The body{"resolved": false}reopens the issue. - Mute notifications (
/issues/:iid/ignore) — events are still counted, but no notifications about the issue ever arrive.{"ignored": false}turns them back on. - Delete —
DELETE /web-script/:id/issues/:iid.
issue-count excludes resolved and muted issues — it is the number for the badge on
the card.
AI analysis of a problem
Section titled “AI analysis of a problem”POST /web-script/:id/issues/:iid/analyze collects evidence (stack, breadcrumbs,
page context), hands it to the model and saves the analysis — the probable cause and
what to check first:
curl -X POST "$NOTIFLY_URL/web-script/77/issues/1234/analyze" \ -H "Content-Type: application/json" \ -H "X-Notifly-Key: <api-token>" \ -d '{"lang": "en"}'Response:
{ "probableCause": "…", "evidence": ["…"], "suggestedSteps": ["…"], "confidence": "medium", "model": "…", "lang": "en", "created": "2026-09-27T10:00:00Z", "stale": false}lang is the analysis language (ru by default), confidence is high, medium
or low; evidence and suggestedSteps hold up to five items.
The analysis is cached by a fingerprint of its inputs: while the issue has no new
events and the language is unchanged, a repeated call returns the saved result —
without calling the model and without charging the AI-helper quota
(aiRequestsPerDay, see quotas). To recompute, pass
{"force": true}. The ready analysis is also available via
GET /web-script/:id/issues/:iid/analysis (204 if there is none yet; stale: true
— the issue got new events after the analysis).
Errors: 422 — the issue has neither a stack nor breadcrumbs, nothing to analyse;
429 — the daily AI request quota is exhausted; 503 — AI is not configured;
502 — the model did not answer or returned an invalid result.
Activity charts
Section titled “Activity charts”Time series take the common parameters ?bucket=1m|1h|1d&from=&to= (RFC3339);
by default — 1h over the last 24 hours, the window is at most 35 days. Minute
data is kept for about a day and daily data for about a month, so use 1d for long
windows.
/series— triggers of trigger scripts (page_open,form_submit,button_click); forconsole_errorsit returns empty./error-series— the dynamics ofconsole_errorserrors broken down by level (info,warning,error,fatal); every accepted event counts, including those below the notification threshold./flush-stats— the distribution of batch send reasons (reason → count:batch,timer,pagehide,visibilitychange,manual, …): shows what share of batches is sent while the page is closing (viasendBeacon).
Security
Section titled “Security”T<token>is generated by a cryptographically secure random generator (160 bits). It is inevitably visible in the page code, so treat it as a “public ingest key”: all it allows is sending events to this script.- The token of an existing script cannot be changed. If the token is abused, delete
the script in the admin or via
DELETE /web-script/:idand create a new one: requests to the old URL will get400and create no notifications. - The
lastUsedfield is updated on every successful trigger — the admin shows when a script sits unused.