Skip to content

Go & Python SDKs

The official SDKs cover the part of the API programs reach for most: sending messages, questions that wait for an answer, heartbeats, monitors and metric ingest. Methods are typed, errors are parsed, retries with Retry-After are already inside — you do not have to assemble HTTP requests to the REST API by hand.

  • Go — module github.com/truboter/notifly.ru/sdk/go, with a single dependency, github.com/coder/websocket (itself dependency-free): the socket is how the SDK learns that a question has been answered.
  • Python — a package imported as notifly, Python 3.9+, with requests as the single dependency.

With the sources at hand, the Go module is wired in with a replace directive in go.mod, and the Python package installs from the directory holding its pyproject.toml (pip install <directory>). The imports in code are:

import notifly "github.com/truboter/notifly.ru/sdk/go"
from notifly import Notifly

A client is built with a token, and the kind of token decides what the client may do. The wrong kind fails with 401; the right kind without the needed permission (sending with a view-only share, say) fails with 403. Issuing and scoping are covered on the API tokens page.

TokenPrefixWhere it goesWhat it unlocks
API tokenMWithAPIToken(…) / Notifly("M…")everything account-level: channels, monitors, heartbeats, scans, history, messages
Channel or share tokenA / SWithChannelToken(…) / Notifly("A…")one channel: sending and the ask flow (needs write permission, else 403), reading messages and the socket (needs read)
Device tokenCsame place as an API tokenthe same routes, but it is a device session rather than an integration key
Heartbeat ping tokenHper callHeartbeatPing / heartbeat_ping
Metric source tokenMper callPushMetric / push_metric — not an API token despite the prefix
Router tokenRper callTriggerRouter / trigger_router — posting to the webhook router’s ingest URL
client := notifly.New(notifly.WithChannelToken("A<channel-token>")) // defaults to https://api.notifly.ru
msg, err := client.Send(ctx, notifly.Message{
Title: "Deploy finished",
Body: "v1.4.2 is live",
Priority: 7, // 0 = use the channel default
Extras: map[string]any{
"client::display": map[string]any{"contentType": "text/markdown"},
},
})
nf = Notifly("A<channel-token>") # defaults to https://api.notifly.ru
nf.send("Deploy finished", "v1.4.2 is live", priority=7)
# Markdown and other extras:
nf.send("Report", "**bold** text", extras_dict={
"client::display": {"contentType": "text/markdown"},
})

Priority, title and the extra fields work exactly as in Sending messages and Message extras.

The script asks and blocks until a human taps a button in the app — an approval gate in front of a dangerous step (details in Questions).

q, err := client.Ask(ctx, notifly.AskRequest{
Question: "Deploy v1.4.2 to production?",
Options: []string{"deploy", "abort"},
DefaultOption: "abort",
TimeoutSec: 600, // auto-expire after 10 minutes
DefaultAnswer: "abort", // submitted automatically on timeout
})
waitCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
ans, err := client.WaitAskAnswer(waitCtx, q.ID, 3*time.Second)
if ans.Status == notifly.AskAnswered && ans.Answer == "deploy" {
// approved
}
q = nf.ask(
"Deploy v1.4.2 to production?",
options=["deploy", "abort"],
default_option="abort",
timeout_sec=600,
default_answer="abort",
)
ans = nf.wait_ask_answer(q["id"], poll=3, timeout=900)
if ans["status"] == "answered" and ans["answer"] == "deploy":
... # approved

In Go, WaitAskAnswer subscribes to the question over WebSocket and returns about a second after the human taps the button. It falls back to polling every pollInterval only where the socket cannot be opened: a self-hosted server without /ws, a proxy that will not upgrade, a channel token without read permission. To skip the socket entirely, use notifly.WithPollingOnly(); that option also disables Subscribe (it returns ErrWebSocketUnavailable right away). The Python SDK always polls, and wait_ask_answer raises NotiflyError when timeout runs out with no answer — the question itself stays open.

GetAskAnswer / get_ask_answer is a single poll if you would rather drive the loop yourself; statuses are pending, answered, expired, cancelled. A worker handling many questions at once wants PendingAnswers + AckAnswers (pending_answers + ack_answers). ExtendAsk (extend_ask_question) pushes the deadline to now + N seconds rather than adding to what is left.

AskRequest has more fields: Fields turns the question into a form (the answer comes back as a JSON object), Next branches — which question to ask next when an option is picked (its id comes back in NextQuestionID) — AllowMulti, JSONFormat, ClientMessageID as an idempotency key, and CallbackURL with CallbackSecret for a webhook on the question’s outcome. In Python they are the matching ask() arguments: fields, next, allow_multi, json_format, client_message_id, callback_url, callback_secret.

To answer a question yourself, use AnswerAsk / AnswerAskMulti (answer_ask_question): that is how an automation closes its own gate once its checks pass. To withdraw a still-open question, use CancelAsk (cancel_ask_question). Both act as the account, so they take an API token rather than a channel token.

Managing the account: channels, monitors, heartbeats

Section titled “Managing the account: channels, monitors, heartbeats”

This is where the API token comes in.

admin := notifly.New(notifly.WithAPIToken("M<api-token>"))
ch, err := admin.CreateChannel(ctx, notifly.ChannelParams{
Name: "deploy-bot",
Description: "CI notifications",
})
fmt.Println("channel token:", ch.Token) // shown only once
_, err = admin.CreateMonitor(ctx, notifly.MonitorParams{
ChannelID: ch.ID,
Name: "example.com",
Kind: notifly.KindHTTP, // 27 kinds, see the Kind* constants
Target: "https://example.com",
IntervalSec: 60,
AlertMessage: "example.com is down",
})
hb, err := admin.CreateHeartbeat(ctx, notifly.HeartbeatParams{
ChannelID: ch.ID,
Name: "nightly-backup",
IntervalSec: 24 * 3600,
GraceSec: 3600, // tolerate an hour of lateness
AlertMessage: "nightly backup did not report",
})
// hb.PingToken is a capability for this heartbeat alone — all the job needs:
err = admin.HeartbeatPing(ctx, hb.PingToken)
admin = Notifly("M<api-token>")
ch = admin.create_channel("deploy-bot", description="CI notifications")
print("channel token:", ch["token"]) # shown only once
admin.create_monitor(
channel_id=ch["id"],
name="example.com",
kind="http", # 27 kinds — see MONITOR_KINDS
target="https://example.com",
interval_sec=60,
alert_message="example.com is down",
)
hb = admin.create_heartbeat(
channel_id=ch["id"],
name="nightly-backup",
interval_sec=24 * 3600,
alert_message="nightly backup did not report",
grace_sec=3600,
)
admin.heartbeat_ping(hb["pingToken"])

Six monitor families share one shape — List/Create/Update/Delete/Pause/Resume: generic (Monitor), HTTP request (HTTPMonitor), TCP ports (PortMonitor), page content (ContentMonitor), multi-step HTTP (WorkflowMonitor) and a real browser scenario (BrowserWorkflow). All but the browser family have a Test* method that probes the target once without creating anything; MonitorHistory and MonitorLog explain why one keeps flapping.

A channel created by a token lands in that token’s scope — otherwise CreateChannel would make a channel its own creator could not see.

Every non-2xx response becomes an error with the body already parsed. You do not have to compare status codes — there are predicates.

_, err := client.Send(ctx, notifly.Message{Body: "hi"})
switch {
case err == nil:
case notifly.IsRateLimited(err): // 429
var apiErr *notifly.APIError
errors.As(err, &apiErr)
time.Sleep(apiErr.RetryAfter)
case notifly.IsAuth(err): // 401/403 — wrong, revoked or read-only token
log.Fatal("check the token kind and its scope")
case notifly.IsQuotaExceeded(err): // 402 — the plan does not allow this
log.Fatal(err)
}
import time
from notifly import NotiflyError
try:
nf.send("t", "m")
except NotiflyError as exc:
if exc.is_rate_limited: # 429
time.sleep(exc.retry_after or 5)
elif exc.is_auth: # 401/403
raise SystemExit("check the token kind and its scope")
elif exc.is_quota_exceeded: # 402
raise SystemExit("the plan does not allow this")
else:
raise

There is also IsNotFound / is_not_found (404), and StatusCode(err) in Go for the raw code (0 for a transport failure). A body over 1 MiB is not truncated in Go but returned as ErrResponseTooLarge — ask for a smaller page.

Two retries by default. A 429 is always retried (the request was rejected, not processed); transport errors and 502, 503, 504 responses only for idempotent GET requests, so sending a message is never silently duplicated. A 500 is not retried. Waits honour Retry-After, falling back to exponential backoff (0.5 s, 1 s, 2 s… capped at 5 s). Python never waits longer than 30 seconds for a single pause (MAX_RETRY_WAIT), even when Retry-After asks for more.

client := notifly.New(
notifly.WithChannelToken("A<channel-token>"),
notifly.WithAPIToken("M<api-token>"),
notifly.WithBaseURL("https://my-notifly.example.com"),
notifly.WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
notifly.WithUserAgent("my-deploy-bot/1.0"),
notifly.WithRetry(0), // disable retries
notifly.FromEnv(), // fill what is still empty from the environment
)
nf = Notifly(
"A<channel-token>",
base_url="https://my-notifly.example.com",
timeout=5.0,
retries=0,
user_agent="my-deploy-bot/1.0",
)

FromEnv() in Go reads NOTIFLY_API_TOKEN, NOTIFLY_CHANNEL_TOKEN and NOTIFLY_DEFAULT_BASE_URL, but only into fields that are still empty — an explicit option always wins. Reading the environment is deliberately not built into New: a client should not pick up an ambient credential from the shell on its own. Python has no environment variables — the token is an argument.

A few more Go client options. WithLogger(*slog.Logger) turns on the diagnostics that are discarded by default: at Debug, one line per request and the socket lifecycle; at Warn, retries. Tokens are masked, and request and response bodies are never logged. WithWSURL sets the socket address when it does not live at /ws on the API host (an AWS API Gateway stage URL, for example); without it the address is derived from the base URL: https → wss, path /ws. The Python client accepts your own requests.Session as session= — for proxies, adapters or a shared connection pool.

A token that only becomes known at runtime (a channel you just created, say) belongs in a Clone:

ch, _ := admin.CreateChannel(ctx, notifly.ChannelParams{Name: "deploys"})
admin.Clone(notifly.WithChannelToken(ch.Token)).Send(ctx, msg)

The Go client’s settings are unexported, so Clone is the only way in: client.ChannelToken = … — which would have raced with requests already in flight and changed the credential for everyone sharing that client — does not compile. A clone reuses the connection pool. Printing a client masks both tokens (apiToken:M...bGh4), so logging one whole is safe.

For an endpoint with no method of its own, Go has Client.Do(ctx, method, path, body, out, opts...) — the same transport, with retries and error parsing. It authenticates with the API token by default; AsChannel() switches to the channel token, AsEither() takes whichever is set (the API token when both are).

Beyond sending, questions, heartbeats and monitors, both SDKs reach the webhook router (rules, the event history, and posting to the ingest URL so a rule can be tested before the real integration fires), channel machine keys and shares, delivery settings with a per-adapter test, metric sources and threshold alerts, status pages (including the public read, which needs no token at all), email inboxes, website error tracking with source-map upload from CI, and the current user with the daily quota breakdown.

Receiving webhooks is part of the SDKs too: Go ships ready-made http.Handlers in its webhook subpackage, Python a verify_signature function. The details and the sharp edges — replay, retries, the two payloads disagreeing about time units — are on the delivery page.

The Go client also has Subscribe — a stream of a channel’s messages and service events over WebSocket, with keepalive and reconnects:

err := client.Subscribe(ctx, notifly.Handler{
OnMessage: func(ctx context.Context, m *notifly.SentMessage) {
fmt.Println(m.Title, m.Message)
},
OnEvent: func(ctx context.Context, e notifly.Event) {
// e.Kind: heartbeat:ping, heartbeat:alert, channel:status_changed, …
},
}, nil) // nil = default *SubscribeOptions; returns when ctx is done

It needs a channel or share token with read permission — the socket does not accept an API token. Subscribe has no polling fallback: when the socket is unavailable it returns ErrWebSocketUnavailable. Python has no realtime client and polls instead.

Account and admin management (users, devices, 2FA, sudo, plan changes, top-up), API tokens themselves, maintenance windows and quiet hours, the AI assistant, AI selector suggestions for content monitors and AI step building for multi-step HTTP and browser scenarios, and the native receivers POST /integrations/alertmanager and POST /integrations/zabbix.

The AI helpers the SDKs do wrap: a router rule from a recorded event (SuggestRouterRule / suggest_router_rule), a mailbox rule from a letter (SuggestEmailInboxRule / suggest_email_inbox_rule) and the analysis of a website error (AnalyzeWebErrorIssue and GetWebErrorIssueAnalysis / analyze_web_error_issue and get_web_error_issue_analysis).

Two public endpoints are left out on purpose: the status page’s anonymous subscribe form, which has no admin side to manage what it creates, and POST /script/{token}, the browser snippet’s own reporting endpoint, which expects exactly the batch shape the snippet builds. All of it is reachable through the REST API directly, or through the MCP server, whose surface is wider.

CI does not need an SDK: a message and a heartbeat ping are one HTTP request each. Keep the channel token and the ping token in repository secrets.

- name: Notify about failure
if: failure()
env:
NOTIFLY_TOKEN: ${{ secrets.NOTIFLY_APP_TOKEN }}
run: |
jq -n --arg t "CI failed: $GITHUB_REPOSITORY" \
--arg m "$GITHUB_WORKFLOW failed on $GITHUB_REF_NAME: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{title: $t, message: $m, priority: 8}' |
curl -sS --fail-with-body -X POST https://api.notifly.ru/message \
-H "X-Notifly-Key: $NOTIFLY_TOKEN" \
-H "Content-Type: application/json" -d @-
- name: Heartbeat "deploys are alive"
env:
NOTIFLY_HB_TOKEN: ${{ secrets.NOTIFLY_HEARTBEAT_TOKEN }}
run: curl -fsS -X POST "https://api.notifly.ru/heartbeat/ping/$NOTIFLY_HB_TOKEN"

The Notifly repository also holds a composite action, github-action/ — bash + curl, no Docker. Its inputs: token (A… for message mode, H… for heartbeat), mode (message or heartbeat), message, title, priority (defaults to "5" and is always sent, so it overrides the channel’s default priority) and url (defaults to https://api.notifly.ru). Network errors, 429 and 5xx are retried, up to three attempts in total; the action has no support for ask questions. It sends the message as a form (application/x-www-form-urlencoded) rather than JSON: if such a step fails with 400, send JSON as in the example above. Most importantly, the repository is private, so uses: truboter/notifly.ru/github-action@main from another repository will not resolve; use the steps above.