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+, withrequestsas the single dependency.
Install
Section titled “Install”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 NotiflyWhich token goes where
Section titled “Which token goes where”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.
| Token | Prefix | Where it goes | What it unlocks |
|---|---|---|---|
| API token | M | WithAPIToken(…) / Notifly("M…") | everything account-level: channels, monitors, heartbeats, scans, history, messages |
| Channel or share token | A / S | WithChannelToken(…) / Notifly("A…") | one channel: sending and the ask flow (needs write permission, else 403), reading messages and the socket (needs read) |
| Device token | C | same place as an API token | the same routes, but it is a device session rather than an integration key |
| Heartbeat ping token | H | per call | HeartbeatPing / heartbeat_ping |
| Metric source token | M | per call | PushMetric / push_metric — not an API token despite the prefix |
| Router token | R | per call | TriggerRouter / trigger_router — posting to the webhook router’s ingest URL |
Send a message
Section titled “Send a message”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.
A question that waits for an answer
Section titled “A question that waits for an answer”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": ... # approvedIn 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.
Errors
Section titled “Errors”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: raiseThere 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.
Retries, timeouts and client options
Section titled “Retries, timeouts and client options”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).
What else the SDKs cover
Section titled “What else the SDKs cover”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 doneIt 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.
What the SDKs do not cover
Section titled “What the SDKs do not cover”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.
GitHub Actions
Section titled “GitHub Actions”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.
Related pages
Section titled “Related pages”- API tokens — issuing the key, access level, scope and expiry.
- REST API — the full endpoint reference.
- CLI client — the same things from a terminal, without code.
- MCP server — when you want an AI agent with tools instead of code.
- Questions, Heartbeat, Monitors — what these calls actually automate.