Reference › Python SDK

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.

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:

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:

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:

fieldmeaning
recordedwhether this call wrote it. False means it was already acknowledged and nothing changed
acknowledged_by / acknowledged_atthe record — on a repeat, the first person’s name and time
statusthe incident’s condition, unchanged by acknowledging it
monitor_idset for a website incident, None for a custom one
subject_id / rule_idset for a custom incident, None for a website one
closed_atset 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:

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.workspacesclient.v2.workspaces
client.v1.regionsclient.v2.locations
client.v1.rulesclient.v2.monitoring.websites
RegionLocation
Rule / CreateRuleRequestWebsiteMonitor / 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.