Skip to content

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.

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.

scriptTypeWhen it triggersWhat goes into message
page_openOn page load (<script> executes)Страница: <title> + URL: <location>
form_submitWhen any <form> on the page is submittedAll non-empty form fields (name: value)
button_clickOn click of an element with the data-notifly attributeThe data-notifly attribute text or the text content + URL
console_errorsWhen a JS error occurs in the browserStacktrace, 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).

  1. Open app.notifly.ruWeb Scripts.
  2. Click Create script, fill in:
    • Name — for display, e.g. “Landing — click “Buy””.
    • Channel — where to send notifications.
    • Event typeОткрытие страницы, Отправка формы or Клик на кнопку.
    • Notification title — default title of the message.
    • Priority — 0 = take the channel’s defaultPriority, otherwise 1–10.
  3. 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>.
Окно терминала
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.

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.

<!-- 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>

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>

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() — explicit console.error calls (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.
  • sendBeaconnavigator.sendBeacon is used when available (doesn’t block navigation / tab close).
  • Final flush — on pagehide the 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>

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

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 -->
FrameworkWhere 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 / Nuxtnuxt.configapp.head.script or app.html
Angularsrc/index.html in <head> before polyfills
Astrosrc/layouts/Layout.astro in <head>
ElectronIn the renderer HTML <head>

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.

  1. Build the frontend with .map files enabled (sourceMap: true).
  2. Upload these .map files to Notifly via the REST API — one map per .js file.
  3. Specify the same release for each .map that you pass to window.NOTIFLY_RELEASE.
  4. When an error is received Notifly finds the .map by release + URL prefix + file name and replaces URL:LINE:COL with src/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.

Окно терминала
# basic authentication — same login/password as for the admin
curl -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:

FieldDescription
fileThe .map file itself (JSON Source Map v3, up to 10 MB)
releaseRelease version — must match window.NOTIFLY_RELEASE
urlPrefixThe URL prefix from which the browser loads this .js (e.g. https://x/static/)
fileNameThe .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.

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/js

Example 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/js
Method and pathPurpose
GET /web-script/:id/sourcemapslist of uploaded maps
POST /web-script/:id/sourcemapsupload a .map (multipart)
DELETE /web-script/:id/sourcemaps/:smiddelete 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.

  • 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 .map is 10 MB.
  • When a web script is deleted all associated .map files 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.

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)”.

Окно терминала
# minimal
curl -fsS "$NOTIFLY_URL/script/T7c2a8f3b1e0d4a6c8e9f" -o /dev/null
# overriding title and text
curl -X POST "$NOTIFLY_URL/script/T7c2a8f3b1e0d4a6c8e9f" \
-H "Content-Type: application/json" \
-d '{"title":"Заявка №42","message":"Имя: Иван\nТелефон: +7..."}'
Method and pathAuthorizationPurpose
GET /web-scriptclient-tokenlist of scripts
POST /web-scriptclient-token (write)create
PUT /web-script/:idclient-token (write)update
DELETE /web-script/:idclient-token (write)delete
GET /web-script/:id/sourcemapsclient-token (write)list source maps
POST /web-script/:id/sourcemapsclient-token (write)upload a .map
DELETE /web-script/:id/sourcemaps/:smidclient-token (write)delete a .map
GET/POST /script/:tokenpublictrigger from browser or script
  • 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 or DELETE /web-script/:id. The old URL will start “eating” requests (quiet 200 OK) without creating notifications.
  • On each successful trigger the lastUsed field is updated — in the admin you can see if a script is “hanging” unused.