Python SDK
A typed client for the REST API.
pip install "uptimer-python-sdk>=1.8.0" # or: uv add "uptimer-python-sdk>=1.8.0"
The SDK version tracks the server it targets. 1.8.x speaks to uptimer 1.8.0 and
later; patch numbers move independently. So install the SDK whose major.minor matches
your server — no compatibility table to look up. client.ensure_compatible() checks it
and fails with a message that names the fix.
Still on API v1? Pin uptimer-python-sdk<1. The server’s v1 is unchanged and
supported, so 0.4.x keeps working against a current server — it just cannot use
anything new. There is no v1 surface left in 1.x: client.v1, its models and its kinds are
gone — client.v2 takes its place.
A complete example
One script, the whole surface: connect, check the server, list what is there, create
website monitoring, read it back, change it, look for incidents, and clean up. Set
UPTIMER_API_KEY and UPTIMER_BASE_URL and it runs as-is.
"""An end-to-end tour of uptimer-python-sdk 1.6.x."""
import os
from uptimer.client import UptimerClient
from uptimer.errors import DefaultUptimerApiError, IncompatibleServerError
from uptimer.models.v2 import (
AGREEMENT_MAJORITY,
STATUS_PENDING,
CreateWebsiteMonitorRequest,
UpdateWebsiteMonitorRequest,
WebsiteMonitorRequest,
WebsiteMonitorResponse,
WebsiteMonitorResponseBody,
)
client = UptimerClient(
api_key=os.environ["UPTIMER_API_KEY"],
base_url=os.environ.get("UPTIMER_BASE_URL", "http://localhost:2517/api"),
)
# For the hosted product, swap the two lines above for:
# from uptimer.client import UptimerCloudClient
# client = UptimerCloudClient(api_key=os.environ["UPTIMER_API_KEY"])
# 1. Fail fast on a server that has no API v2, with a message that names the fix
# rather than a 404 on the first real call. Both this and client.version()
# read the shared, unversioned /version.
try:
print("server:", client.check_compatibility())
except IncompatibleServerError as exc:
raise SystemExit(str(exc)) from exc
print("version:", client.version()) # same endpoint, no compatibility gate
# client.ensure_compatible() is the same check, run at most once per client.
# 2. Everything the API versions is reached through client.v2.
workspace = client.v2.workspaces.all()[0]
print("workspace:", workspace.id, workspace.name, f"({workspace.role})")
locations = client.v2.locations.all()
for location in locations:
print("location:", location.name, location.active_workers_count, "worker(s)")
# 3. Create website monitoring. This one call also creates the monitoring
# subject, its built-in HTTP signal and its Reachability rule.
monitor = client.v2.monitoring.websites.create(
CreateWebsiteMonitorRequest(
name="Checkout API",
interval=60, # seconds, in whole minutes (>= 60)
workspace_id=workspace.id, # required on create; fixed afterwards
request=WebsiteMonitorRequest(
url="https://checkout.example/health",
method="GET", # GET, POST, PATCH or OPTIONS
),
response=WebsiteMonitorResponse(
statuses=[200],
body=WebsiteMonitorResponseBody(content=""), # "" = don't check the body
),
locations=[loc.name for loc in locations[:1]], # names, not ids
agreement=AGREEMENT_MAJORITY, # "any" | "majority" | "all"
),
)
print("created:", monitor.id, monitor.locations, monitor.agreement)
try:
# 4. Read it back, in the workspace listing and on its own.
listed = client.v2.monitoring.websites.all(workspace.id)
print("monitors here:", [m.name for m in listed])
fetched = client.v2.monitoring.websites.get(monitor.id)
print("fetched:", fetched.name, fetched.request.url, fetched.response.statuses)
# 5. Update replaces the whole configuration — send every field you want to
# keep. There is no workspace_id: a monitor cannot change workspace. An
# omitted agreement keeps the stored one instead of resetting it.
updated = client.v2.monitoring.websites.update(
monitor.id,
UpdateWebsiteMonitorRequest(
name="Checkout API (health)",
interval=120,
request=WebsiteMonitorRequest(
url="https://checkout.example/health",
method="GET",
),
response=WebsiteMonitorResponse(
statuses=[200, 204],
body=WebsiteMonitorResponseBody(content="ok"),
),
locations=fetched.locations, # replaces the stored list
),
)
print("updated:", updated.interval, updated.agreement) # agreement survived
# 6. What is wrong right now. Only open incidents come back, newest trouble
# first. A monitor created seconds ago has nothing open yet — this is the
# loop you would run against a workspace that has been up for a while.
for incident in client.v2.incidents.all(workspace.id):
note = " (nobody notified yet)" if incident.status == STATUS_PENDING else ""
print(f"{incident.monitor_name}: {incident.status}{note}")
print(" since ", incident.trouble_since)
print(" failing:", incident.locations.failing or "-")
print(" unknown:", incident.locations.unknown or "-")
# Narrow it to one monitor:
mine = client.v2.incidents.all(workspace.id, monitor_id=monitor.id)
print("open on this monitor:", len(mine))
except DefaultUptimerApiError as exc:
# The API answered with an error envelope. Branch on .code — see Errors below.
print("api error:", exc.code, exc.error_type, exc.message, exc.details)
finally:
# 7. Clean up whatever happened above. Deleting the monitor removes its
# subject, signal, rule and history with it.
deleted = client.v2.monitoring.websites.delete(monitor.id)
print(deleted.message, deleted.monitor_id)
The rest of this page explains the pieces.
What is available
Resources live under the API version that serves them. The REST API is
versioned by path, and the SDK keeps that
version visible rather than hiding it: everything v2 offers is reached through
client.v2, and there are no root-level aliases.
client.v2.workspaces.all()client.v2.locations.all()client.v2.incidents.all(workspace_id, monitor_id=None)— open incidentsclient.v2.monitoring.websites.all(workspace_id)·.get(id)·.create(...)·.update(id, ...)·.delete(id)client.v2.subjects.all(workspace_id)·.get(slug, workspace_id=None)·.create(...)— new in 1.6.0, see Subjectsclient.v2.subjects(subject).signals(signal).observations.create(...)— new in 1.6.0, see Reporting observationsclient.v2.subjects(subject).incidents.all()·client.v2.subjects(subject).incidents(id).acknowledge()— new in 1.7.0, see Acknowledging an incidentclient.v1.rules(monitor_uid).incidents(id).acknowledge()— new in 1.7.0, the website half of the same thingclient.v2.subjects(subject).maintenance.get()·.start(ends_at)·.update_end(ends_at)·.cancel()— new in 1.7.0, see Maintenance windows
subjects is both a collection and a path: call the methods on it to list, fetch or
create a subject, and call it with a slug to reach what is under one.
The types those calls take and return are versioned the same way — import them from
uptimer.models.v2:
from uptimer.models.v2 import CreateWebsiteMonitorRequest, Incident, Location
They are not exported from uptimer.models, and there are no flat aliases, so a stale
import fails loudly rather than binding to something else.
Two things stay off the version namespaces, because
GET /version is shared by both
API versions rather than belonging to either:
client.version()— the server versionclient.check_compatibility()/client.ensure_compatible()
The deserialization exceptions (ModelError, TypeMismatchError, …) stay on
uptimer.models for the same reason: the same error is raised whichever API version
produced the payload.
Website monitoring sits under client.v2.monitoring because it is a built-in template,
not the general monitor model.
client.v1 holds exactly one thing: website incident acknowledgement, which the
API serves under /v1/rules/{uid}/incidents/{id}/acknowledge because website
monitoring is v1’s resource. This is still a v2 client — there is no rule listing,
create, update or delete under client.v1, and monitors are read and written through
client.v2.monitoring.websites.
Every model carries the API’s kind, and the SDK strips kind out of anything it
sends: it is the server telling you what an object is, not a field you set.
Checking the server first
check_compatibility() reads /version — the one unversioned endpoint, so it works
against a server too old for the rest of the SDK — and raises IncompatibleServerError
if that server predates API v2. The bar is the SDK’s own major.minor: 1.6.x needs
uptimer 1.6.0+. A server reporting something that is not a release number, such as a
dev build from source, is treated as usable rather than locked out.
The hosted service at myuptime.info versions on its own line
(15.x), far above that number, so the check always passes there. A /v2 route a hosted
release does not serve yet raises IncompatibleServerError when you call it, rather than
a bare 404.
Subjects
New in 1.6.0. A subject is one thing a
workspace watches. client.v2.subjects is the Custom half of the API: website
monitoring stays on client.v2.monitoring.websites, and the two never serve each
other’s subjects.
from uptimer.models.v2 import CreateSubjectRequest
subjects = client.v2.subjects
# The workspace's Custom subjects. Website checks are not here.
for subject in subjects.all(workspace.id):
print(subject.id, subject.subject_kind, subject.signal_count, subject.rule_count)
# A Custom subject of your own. It arrives empty: no signal, no rule, no probe.
created = subjects.create(
CreateSubjectRequest(name="Payments worker", workspace_id=workspace.id),
)
assert created.is_custom and created.signal_count == 0
# One by its slug. workspace_id settles the case where the same slug exists in two
# of your workspaces; without it the server searches your memberships.
fetched = subjects.get(created.id, workspace_id=workspace.id)
subject.id is the slug, which is what the API addresses a subject by and the first
half of the observation route. subject_kind reads SUBJECT_KIND_CUSTOM on everything
these calls return — is_custom is the typed way to read it, and SUBJECT_KIND_WEBSITE
stays in the model for a payload from an older server. That is separate from kind,
which is always "subject".
Website monitoring is not created here — client.v2.monitoring.websites.create(...)
is, and asking for subject_kind="website" raises a DefaultUptimerApiError saying so.
Passing a website subject’s slug to any subjects call raises the same error. There is
no update and no delete: deleting a subject takes its whole history with it.
New in SDK 1.8.0: signals and rules are authored here too.
client.v2.subjects(slug).signals and .rules each list, create, get, update and
delete, with typed policy models for a rule’s document — and reporting observations to
an existing signal is unchanged. One spelling to know: a rule input that cites another
rule is from_rule in Python, because from is a keyword, and it is sent and received
as from. The routes underneath are Custom signals
and Custom rules.
Alert destinations, transformations and delivery
New in 1.8.0, and wrapped by SDK 1.8.0. Where a workspace’s alerts go, what they look like, which destinations one subject tells, and what was actually sent:
notifications = client.v2.notifications
# Destinations: list/create/get/update/delete, plus the three that are not CRUD.
notifications.destinations.all("your-workspace-id")
notifications.destinations.set_enabled(1, enabled=False, workspace_id="your-workspace-id")
notifications.destinations.make_default(1, workspace_id="your-workspace-id")
notifications.destinations.send_test(1, workspace_id="your-workspace-id") # a real send
# Transformations: the same five, plus the vocabulary and a dry run.
notifications.transformations.samples()
notifications.transformations.preview('{"event": "{{ kind }}"}', workspace_id="your-workspace-id")
# What was actually sent, kept 30 days.
notifications.deliveries.all(workspace_id="your-workspace-id", undelivered=True)
# Which destinations one subject tells. The table IS the resource: a save replaces it.
delivery = client.v2.subjects("payments-worker", "your-workspace-id").delivery
delivery.get()
delivery.clear()
# A website monitor carries the same table on its own collection.
client.v2.monitoring.websites("monitor-id").delivery.get()
The models are typed the way the rest of the SDK is — Destination, Transformation,
TransformationPreview, SubjectAlertDelivery, DeliveryRecord — and the rules the
screens enforce are the server’s, so they apply here unchanged: a transformation is
stored only once it renders all three sample messages, transformation_id=None means
Uptimer’s built-in body, and a test send is a real send with a real delivery record.
Reading a destination or the delivery log needs the editor role, not just membership: a destination holds a webhook URL. The routes underneath are Notifications.
Reporting observations
New in 1.6.0. Send your own readings to a custom heartbeat or event signal of a Custom subject. The two slugs are the address, and both are shown on the signal’s page in the dashboard.
from uptimer.client import UptimerClient
from uptimer.models.v2 import (
OBSERVATION_STATUS_OK,
OBSERVATION_STATUS_PROBLEM,
CreateObservationRequest,
)
client = UptimerClient(api_key="...", base_url="http://127.0.0.1:2517/api")
observations = client.v2.subjects("payments-worker").signals("worker-pulse").observations
# A heartbeat: "I ran, and I am fine."
observations.create(CreateObservationRequest(status=OBSERVATION_STATUS_OK))
# Everything except status is optional.
stored = observations.create(
CreateObservationRequest(
status=OBSERVATION_STATUS_PROBLEM,
observed_at="2026-09-01T12:00:00Z", # RFC 3339; omit to mean now
value=0.0, # a rule can compare this with < or >
error="queue backlog over threshold",
labels={"instance": "worker-3", "env": "prod"},
),
)
print(stored.accepted, stored.reject_reason)
accepted reports acceptance, not health: it says the observation was stored and may
be evaluated, not that anything is wrong or fine. An observation Uptimer keeps but will not
evaluate — one stamped too far ahead, say — comes back with accepted=False and a
reject_reason such as clock_skew. It is returned, not raised: it was received. An
exception means nothing was stored.
Retries are safe. An observation is identified by its signal, its observed_at and its
labels, so re-sending the same one replaces it rather than counting twice.
Posting to the platform HTTP signal is refused with DefaultUptimerApiError — that
stream belongs to Uptimer’s own probe. There is no SDK path for creating signals or rules;
those are dashboard work.
Locations and agreement
Assign locations with the locations field. It takes
location names, as listed by client.v2.locations.all() — not ids. A monitor with
none is never checked and stays at no data.
agreement is how many of those locations must report a problem before the monitor
does: AGREEMENT_ANY, AGREEMENT_MAJORITY or AGREEMENT_ALL from uptimer.models.v2
(the wire values are "any", "majority" and "all").
Two things behave differently between CreateWebsiteMonitorRequest and
UpdateWebsiteMonitorRequest:
workspace_idexists only on create. A monitor cannot change workspace, so the update request has no such field.- On update,
locationsreplaces the stored list — include the ones you want to keep — while an omittedagreementkeeps the stored value rather than resetting it to the default.
Incident status
client.v2.incidents.all() returns only open incidents, newest trouble first. Pass
monitor_id= to narrow it to one monitor. Closed incidents are history and live on the
subject timeline in the dashboard; there is no incident-history endpoint in this release.
status carries the same words the dashboard shows — STATUS_OK, STATUS_PENDING,
STATUS_PROBLEM, STATUS_NO_DATA, STATUS_RECOVERING. pending is the one to watch:
the monitor is failing but still inside the confirm hold, so nobody has been notified
yet.
incident.locations splits the evidence into .failing, .unknown and .ok. A
location that has never reported stays in unknown and still counts toward the
agreement — that is a real state, not a missing one.
Acknowledging an incident
New in 1.7.0. Acknowledging says a person has seen an open incident. It changes nothing the engine decided — the verdict, the evidence and the close hold all carry on — and it is recorded once, with who and when. Its one effect on alerting is that the four-hour reminders for that incident stop. See Acknowledging an incident for what it means, and the REST reference for the routes underneath.
Each kind of monitoring acknowledges through its own family, the same split subjects follow. There is deliberately no kind-agnostic method: acknowledging is a claim about one specific incident, and a call that guessed the family could claim the wrong one.
What you need. These methods are in the published 1.7.0 package
(pip install "uptimer-python-sdk>=1.7.0"). They also need this server: the routes
arrive with uptimer 1.7.0, so a 1.6.x server answers nothing useful.
Custom monitoring — list the subject’s open incidents, choose one, acknowledge it by id:
subject = client.v2.subjects("payments-worker", "your-workspace-id")
open_incidents = subject.incidents.all()
for incident in open_incidents:
print(incident.id, incident.rule_name, incident.status, incident.acknowledged)
# Nothing open is an ordinary answer, not an error.
if open_incidents:
# Acknowledge by the id the listing gave you — never a guessed or stored one.
target = open_incidents[0]
record = subject.incidents(target.id).acknowledge()
print(record.acknowledged_by, record.acknowledged_at, record.recorded)
subject.incidents.all() lists every open incident of that subject, newest trouble
first — pending, recovering and no-data included, and already-acknowledged ones too.
A subject has one open incident per rule, so each carries rule_id and rule_name;
nothing open returns []. The second argument to subjects(...) is the workspace, and
it settles an ambiguity rather than being required: pass it when the same slug exists in
two of your workspaces, and both the listing and the acknowledgement carry it.
Website monitoring — the ids come from the workspace incident list, which has named each incident’s monitor since 1.5.0:
# An empty list means nothing is wrong: the loop simply does not run.
for incident in client.v2.incidents.all("your-workspace-id"):
record = client.v1.rules(incident.monitor_id).incidents(incident.id).acknowledge()
print(record.incident_id, record.acknowledged_by, record.recorded)
There is no body and no actor argument either way: the person recorded is the owner of the API key, at the time of the call.
What IncidentAcknowledgement says:
| field | meaning |
|---|---|
recorded | whether this call wrote it. False means it was already acknowledged and nothing changed |
acknowledged_by / acknowledged_at | the record — on a repeat, the first person’s name and time |
status | the incident’s condition, unchanged by acknowledging it |
monitor_id | set for a website incident, None for a custom one |
subject_id / rule_id | set for a custom incident, None for a website one |
closed_at | set if the incident had already closed |
Repeating is safe: no second history entry, and the original name and time come
back with recorded=False, so a retry after a timeout is not a second claim.
Refusals are raised, not worked around. DefaultUptimerApiError means the incident
is not this parent’s — another monitor’s, another subject’s, another workspace’s, or the
other kind of monitoring. Neither method retries through the other family.
Closing cuts both ways. A first acknowledgement of an incident that has already
closed is refused (Incident is closed) — there is nothing left to be on, and anything
open now is a different incident. An incident acknowledged while it was open keeps
that record after closing, so asking again is not an error: it answers the original name
and time with recorded=False and closed_at set.
Maintenance windows
New in 1.7.0, and it needs an uptimer 1.7.0+ server (same availability note as acknowledgement: the SDK methods are the 1.7.0 release, not the published 1.6.0 package). A window holds back one subject’s problem notifications until a time you choose. Monitoring, incidents and the timeline are untouched, and recoveries are never held back.
maintenance = client.v2.subjects("payments-worker", "your-workspace-id").maintenance
# Is it silenced right now? None means nothing is scheduled — an answer, not an error.
window = maintenance.get()
if window is None:
window = maintenance.start("2026-09-13T18:00:00Z")
print(window.active, window.ends_at, window.muted)
# The work is taking longer: move the end of the SAME window.
maintenance.update_end("2026-09-13T20:00:00Z")
# When it is done.
maintenance.cancel()
ends_at is RFC 3339 and carries its own zone, so there is nothing to guess. The window starts
immediately; there is no future start and no recurring schedule.
update_end is a real update rather than a cancel and a new window: it keeps the window’s
identity and its start, nothing sees the subject briefly leave maintenance, and nobody is
notified. Moving the end into the past raises rather than stopping the window — to stop it now,
cancel().
MaintenanceWindow tells its three states apart by its fields: active true is running,
cancelled_at set is ended early, and neither is a window that ran out. muted is what waits,
in the server’s own words — a recovery is never in it.
Refusals are raised as DefaultUptimerApiError: a time that has already passed, a window
already running (cancel it first), a website subject — those are managed from the dashboard —
or a caller who is not an editor. Reading takes the viewer role.
Errors
All of these subclass UptimerError, in uptimer.errors:
DefaultUptimerApiError— the API returned anerrorenvelope. Carries.code,.error_type,.messageand.details; branch on.code, which is finer-grained than.error_type(see Errors).IncompatibleServerError— the server does not provide API v2. Carries.server_version. Upgrade the server, or useuptimer-python-sdk<1.UptimerInvalidHttpCodeError— a genuine non-200 transport error. Carries.urland.status_code.UptimerInvalidResponseError— the body was not the expected envelope.
Migrating from 0.4.x
Coming from 1.5.x? There is nothing to change. 1.6.x is the same v2 surface plus observations; upgrade the SDK when you upgrade the server.
| 0.4.x (API v1) | 1.x (API v2) |
|---|---|
client.v1.workspaces | client.v2.workspaces |
client.v1.regions | client.v2.locations |
client.v1.rules | client.v2.monitoring.websites |
Region | Location |
Rule / CreateRuleRequest | WebsiteMonitor / CreateWebsiteMonitorRequest |
regions=[...] | locations=[...] |
| — | agreement=..., client.v2.incidents |
| — | client.check_compatibility() |
from uptimer.models import … | from uptimer.models.v2 import … |
The version namespace is the shape you already know. 0.4.x reached API v1 through
client.v1; 1.6.x reaches API v2 through client.v2. What moves is the version, not
the pattern — and the types moved with it: uptimer.models becomes
uptimer.models.v2.
client.version() is unchanged and still sits on the client, not under client.v2 —
/version is a shared global endpoint, not a versioned one.
UptimerClient(api_key=…, base_url=…) and UptimerCloudClient(api_key=…) are
constructed exactly as before, and both expose the same client.v2.
The rename is not only the namespace: regions=[...] becomes locations=[...], and
what 0.4.x called a rule is a website monitor. What the server stores is the same
object, so a monitor created with 0.4.x is the one 1.6.x reads back.
Releases and release notes on PyPI.