Status Hub Pages, Incidents and Maintenance with the AlertOps SDKs
Build status pages from services and service groups, change pages with status_hub.pages.patch (PUT replaces the whole record), and post incidents and maintenance with the SDKs' quiet defaults so subscribers are only notified when you say so.
Overview
Relevant for Developers publishing service status to customers or staff
Status Hub publishes the health of your services. You keep a catalog of services (optionally in service groups), lay them out on status pages, and post incidents and scheduled maintenance that the pages show. Subscribers can follow a page by email and other channels. The SDKs cover the management API (status_hub, v3) and the viewer API that renders pages (status_viewer).
Key capabilities:
- Whole-record safety — status_hub.pages.patch and status_hub.maintenance.patch read the record, overlay your changes and write it back.
- Quiet defaults — incidents, updates and maintenance are created with notifySubscribers: false unless you opt in.
- Layout helper — status_hub.pages.services.set_layout builds the grouped-services layout correctly.
- Viewer access — status_viewer.public reads published pages with no credentials. status_viewer.internal reads internal pages with the API key.
Reading the operation names
Relevant for Developers using any of the five AlertOps SDKs
This guide applies to every AlertOps SDK. It names each operation by its resource path and operation name, such as groups.members.add_users, lists positional arguments in order, and shows request bodies and options as JSON. Where a task needs real code, the example is given in all five languages. Each SDK spells the same call in its own style:
| SDK | The same call | Options and request fields |
| Python | ao.groups.members.add_users(group_id, [101, 102], roles=["Primary"]) | Keyword arguments, such as search="Net" |
| TypeScript | await ao.groups.members.addUsers(groupId, [101, 102], ["Primary"]) | One trailing options object keyed by the names in this guide, such as { search: "Net" } |
| Go | ao.Groups.Members.AddUsers(ctx, groupID, []any{101, 102}, []any{"Primary"}) | &alertops.Request{Fields: alertops.M{"search": "Net"}} for endpoints, alertops.M{…} for helper options |
| Java | ao.groups().members().addUsers(groupId, List.of(101, 102), List.of("Primary")) | Request.of("search", "Net") for endpoints, a Map keyed by the names in this guide for helper options |
| .NET | await ao.Groups.Members.AddUsersAsync(groupId, new object?[] { 101, 102 }, new object?[] { "Primary" }) | new Kwargs { ["search"] = "Net" } |
- Names follow each language's style: update_basic is updateBasic in TypeScript and Java, UpdateBasic in Go and UpdateBasicAsync in .NET. Go keeps initialisms upper case (create_api is CreateAPI, post_to_url is PostToURL).
- Paginated operations also have variants that collect every page or iterate lazily (…All / …Iter; in Python, .all() and .iter() on the operation).
- Helpers that look something up return null (None in Python, nil in Go) when nothing matches. **ensure helpers** return the record together with a flag that says whether it was created.
- Errors are the same classes in every SDK, such as NotFoundError and BadRequestError.
- Code examples come in the order Python, TypeScript, Go, Java, .NET. Each assumes a client named ao (and, in Go, a ctx and a function that returns error), created as in that SDK's Getting Started article.
For one language's details, see Calling the API from Python, TypeScript, Go, Java or .NET.
Before you start
Relevant for Developers setting up the client
Every operation in this guide hangs off status_hub (pages, services, service groups, incidents, maintenance, templates, notifications) or status_viewer on the client. Status Hub uses numeric codes:
| Field | Values |
| Incident status | 1 Investigating, 2 Identified, 3 Monitoring, 4 Resolved |
| Incident impact | 1 None, 2 Minor, 3 Major, 4 Critical |
| Service currentStatus | 1 Operational, 3 Partial Outage |
| Maintenance status (writes) | 1 Scheduled, 2 In Progress |
| Access permissionId | 1 Owner, 2 Edit, 3 View |
Services, group memberships, incident and maintenance history, and subscribers are cursor-paged (pageSize plus after). The paginated variants (…All / …Iter) follow the cursor for you. Pages and service groups are bare lists.
Status pages are capped per account
On the tested Enterprise Trial plan, the account allows 3 status pages in total. A fourth status_hub.pages.create fails with 402 StatusPage.MaxTotalPages (PaymentRequiredError), even though the account limits read -99 (unlimited). Reuse pages in tests instead of creating new ones.
Create a status page
Relevant for Developers creating and publishing a page
status_hub.pages.create always creates a draft and returns the page with its statusPageId. timezoneId is the numeric id of the zone in lookups.time_zones() (for example, the entry whose zone is America/Chicago).
{
"pageSlug": "acme-status", "displayName": "Acme Service Status",
"description": "Live status of Acme services",
"visibilityType": "public", "timezoneId": 11,
"customDomain": {"domain": null, "subdomainAlias": "acme-status"},
"allowSubscriptions": true, "enabledChannels": ["Email"]
}
| visibilityType | Who can see the page |
| public | Anyone, at the subdomain alias (or a custom domain) |
| private_sso | Visitors who sign in through the page's SAML identity provider |
| internal | Users of your AlertOps account, through the internal viewer. Create it with customDomain: null, allowSubscriptions: false and enabledChannels: [] |
The SDKs fill in isPublished: false, showUptimeHistory: true and a default branding (primary colour #0ea5e9) when you leave them out.
Change a page
pages.update replaces the whole page
PUT /status_pages/{id} is a whole-record replace and requires pageSlug and branding (400 without them). Use status_hub.pages.patch(pid, changes), which reads the page, keeps every writable field and overlays yours.
| Operation | Effect |
| status_hub.pages.patch(pid, {displayName: "Acme Status", branding: {…, primaryColor: "#112233"}}) | Changes the name and colour, keeps everything else. Send the page's whole branding object with your change |
| status_hub.pages.publish(pid) | Returns nothing; the page is now served publicly |
| status_hub.pages.unpublish(pid) | Returns nothing |
Branding, custom domains and SSO
- Logo and favicon — status_hub.pages.assets.upload_logo(pid, file) uploads a multipart file part (for example logo.png, image/png) and sets branding.logoUrl itself. status_hub.pages.assets.delete(pid, "logo") clears it.
- Custom domain — patch the page's customDomain.domain first. status_hub.pages.custom_domains.create(pid, {domain}) is 400 until the page carries the domain. It returns a Pending verification with dnsRecordName and dnsRecordValue for your DNS. To clear the domain, patch domain: "". null keeps the old value.
- SSL — status_hub.pages.ssl.warmup(pid) is 400 "Custom domain is not verified" until DNS verifies. status_hub.pages.ssl.refresh(pid) works regardless.
- SSO — status_hub.pages.sso.update(pid, {samlMetadataUrl, samlSignOnUrl, samlSignOutUrl, samlCertificate}) switches the page to private_sso, and status_hub.pages.sso.delete(pid) switches it back to public. samlCertificate must be a parseable X.509 certificate (base64 DER), or the viewer's SSO login fails with 400 "ASN1 corrupted data". status_hub.pages.sso.get is 404 until a config exists.
Services, groups and page layout
Relevant for Developers maintaining the service catalog
Services and service groups are account-wide. A page shows the ones in its layout.
| Step | Operation | Result |
| 1 | status_hub.services.create({name: "Checkout API", description: "Payments endpoint"}) | Service with serviceId (currentStatus 1) |
| 2 | status_hub.services.create({name: "Storefront Web"}) | A second service |
| 3 | status_hub.service_groups.create({name: "Commerce", description: "Customer-facing"}) | Group with serviceGroupId |
| 4 | status_hub.service_groups.services.add(gid, {services: [{serviceId: 201, displayOrder: 0}, {serviceId: 202, displayOrder: 1}]}) | Both services in the group |
| 5 | status_hub.pages.services.set_layout(pid, {groups: {103: [201, 202]}}) | The page shows the group with its services |
status_hub.pages.services.set_layout(pid, {service_ids: [201], groups: {103: [202, 203]}}) replaces the page layout: grouped services first, then ungrouped ones, all visible.
A group item alone attaches no services
In status_hub.pages.services.add and update, an item {"type": "group", "groupId": ...} only carries the group's display options. Each grouped service must be its own item {"type": "service", "serviceId": ..., "groupId": ...}. set_layout sends both kinds for you.
Delete in order: empty groups first
Deleting a service that belongs to a service group, or a group that still has services, fails with 409 ConflictError ("referenced by ..."). Remove memberships with status_hub.service_groups.services.remove(gid, {serviceIds: [202, 203]}) first, then delete the group, then the services.
Incidents, updates and postmortems
Relevant for Incident commanders communicating an outage
Open an incident, post an update, then resolve it:
Python
inc = ao.status_hub.incidents.create(title="Checkout errors", description="<p>Some payments fail.</p>",
status=1, impact=3, affectedServiceIds=[api_id])
ao.status_hub.incidents.updates.create(inc["incidentId"], status=2, message="Cause identified: a bad deploy.")
ao.status_hub.incidents.resolve(inc["incidentId"], "Rolled back. All payments succeed.")
TypeScript
const inc = await ao.statusHub.incidents.create({
title: "Checkout errors", description: "<p>Some payments fail.</p>",
status: 1, impact: 3, affectedServiceIds: [apiId],
});
await ao.statusHub.incidents.updates.create(inc.incidentId, { status: 2, message: "Cause identified: a bad deploy." });
await ao.statusHub.incidents.resolve(inc.incidentId, "Rolled back. All payments succeed.");
Go
inc, err := ao.StatusHub.Incidents.Create(ctx, &alertops.Request{Fields: alertops.M{
"title": "Checkout errors", "description": "<p>Some payments fail.</p>",
"status": 1, "impact": 3, "affectedServiceIds": []any{apiID},
}})
if err != nil {
return err
}
iid := alertops.Get(inc, "incidentId")
if _, err = ao.StatusHub.Incidents.Updates.Create(ctx, iid,
&alertops.Request{Fields: alertops.M{"status": 2, "message": "Cause identified: a bad deploy."}}); err != nil {
return err
}
_, err = ao.StatusHub.Incidents.Resolve(ctx, iid, "Rolled back. All payments succeed.", false)
Java
JsonNode inc = ao.statusHub().incidents().create(Request.of(
"title", "Checkout errors", "description", "<p>Some payments fail.</p>",
"status", 1, "impact", 3, "affectedServiceIds", List.of(apiId)));
JsonNode iid = inc.get("incidentId");
ao.statusHub().incidents().updates().create(iid, Request.of("status", 2, "message", "Cause identified: a bad deploy."));
ao.statusHub().incidents().resolve(iid, "Rolled back. All payments succeed.", false);
.NET
var inc = await ao.StatusHub.Incidents.CreateAsync(new Kwargs
{
["title"] = "Checkout errors", ["description"] = "<p>Some payments fail.</p>",
["status"] = 1, ["impact"] = 3, ["affectedServiceIds"] = new[] { apiId },
});
var iid = inc!["incidentId"]!;
await ao.StatusHub.Incidents.Updates.CreateAsync(iid, new Kwargs { ["status"] = 2, ["message"] = "Cause identified: a bad deploy." });
await ao.StatusHub.Incidents.ResolveAsync(iid, "Rolled back. All payments succeed.");
resolve(incident_id, message, notify_subscribers) posts a status-4 update and returns the incident (status 4, resolvedAt set). incidents.updates.list returns the newest update first. status_hub.incidents.update(iid, {title, impact}) changes the incident itself.
Subscribers are only notified when you ask
The SDKs send notifySubscribers: false on incidents.create and incidents.updates.create unless you pass notifySubscribers: true (or pass true as resolve's notify_subscribers argument). The API itself has no such safety.
Postmortems hang off the incident. Their fields are HTML strings, and only a published postmortem appears on the public page. Create one with status_hub.incidents.postmortem.create(iid, body), then publish it with status_hub.incidents.postmortem.publish(iid), which returns the postmortem with isPublished: true.
{
"summary": "<p>Deploy 418 broke card tokenisation.</p>",
"rootCause": "<p>Config typo.</p>",
"impact": "<p>12% of payments failed for 25 minutes.</p>",
"resolution": "<p>Rollback.</p>",
"actionItems": "<p>Add a config check to CI.</p>"
}
status_hub.incidents.postmortem.get is 404 until a postmortem exists.
Scheduled maintenance
Relevant for Ops teams announcing planned work
status_hub.maintenance.create(body) takes ISO 8601 UTC date-times without fractional seconds and returns the window with its maintenanceWindowId:
{
"title": "Database upgrade", "description": "<p>Read-only for 1 hour.</p>",
"scheduledStartDate": "2026-11-15T02:00:00Z", "scheduledEndDate": "2026-11-15T03:00:00Z",
"affectedServiceIds": [201]
}
The SDKs create maintenance quietly: notifySubscribers, sendReminderAtStart, sendReminderAtEnd, autoStartEnabled and autoCompleteEnabled are false, and reminderHoursBeforeStart is 1 (the API requires at least 1 even with reminders off).
A maintenance PUT turns notifications back on
PUT /status_pages/maintenance_windows/{id} resets notifySubscribers, sendReminderAtStart and sendReminderAtEnd to true when they are omitted, so a plain update can notify subscribers. Use status_hub.maintenance.patch(mid, changes), which reads the window and sends every flag back.
To finish a window, move it to In Progress, then complete it. complete works only on status 2 (In Progress) and is 400 otherwise.
- status_hub.maintenance.patch(mid, {status: 2})
- status_hub.maintenance.complete(mid, {actualEndDate: "2026-11-15T02:55:00Z"})
Create and update responses show affectedServices: [] even though the services are attached; status_hub.maintenance.upcoming({daysAhead: 30}) and maintenance.list show them. Maintenance updates (maintenance.updates.*) are dead routes (404).
Templates, subscribers and access
Relevant for App Admins standardising messages and controlling who manages pages
Templates
status_hub.templates.incident.create(body) creates an incident template:
{
"name": "Payment outage", "titleTemplate": "Payment processing issues",
"descriptionTemplate": "<p>We are investigating.</p>",
"defaultImpact": 3, "defaultStatus": 1, "defaultServiceStatus": 3, "defaultServiceIds": [201],
"updateTemplates": [{"message": "Investigating", "status": 1, "notifySubscribers": false}]
}
status_hub.templates.maintenance.create({name: "DB upgrade", titleTemplate: "Database upgrade", descriptionTemplate: "<p>Read-only mode.</p>"}) creates a maintenance template. status_hub.templates.list_all() returns both kinds, each tagged with type incident or maintenance. Template lists default to active templates; pass activeOnly: false for all.
Subscribers
The subscriber list hides unverified subscribers
status_hub.pages.subscribers.list(pid) returns verified subscribers only. Pass status: "all" (or "unverified") to see pending ones, for example with the paginated variant of status_hub.pages.subscribers.list.
subscriberType is 1 Email, 2 SMS, 3 Webhook, 4 Slack, 5 Teams. status_hub.pages.subscribers.delete(pid, subscriber_id) removes one.
Access
Pages, services and service groups share one access API: grant, revoke, update, restrict, unrestrict, permission and transfer_ownership.
| Operation | Result |
| status_hub.pages.access.grant(pid, {user_ids: [101], permission: "view"}) | Returns the access list |
| status_hub.pages.access.restrict(pid) | Custom (restricted) access on |
| status_hub.pages.access.permission(pid) | isRestricted is now true |
unrestrict drops the custom grants. transfer_ownership(eid, {newOwnerUserId}) is one-way for the caller: afterwards a transfer back fails with 400 "Current owner and new owner must be different users". An unrestricted object stays editable and deletable by an admin key.
The public and internal viewer
Relevant for Developers embedding or checking what visitors see
status_viewer.public calls the anonymous {v3}/public/* API. It sends no credentials, and only published pages are served (404 otherwise). Resolve the organization and page slugs from the subdomain alias first.
| Operation | Returns |
| status_viewer.public.resolve("acme-status") | The organization and page slugs (from by_alias(alias)) |
| status_viewer.public.page(org, slug) | name, overallStatus, requiresSsoAuthentication … |
| status_viewer.public.services(org, slug, {grouped: true}) | {groups[], ungroupedServices[]} |
| status_viewer.public.active_incidents(org, slug) | Open incidents |
| status_viewer.public.incidents(org, slug, {startDate: "2026-11-01", endDate: "2026-11-05"}) | Incident history |
status_viewer.public.subscribe(org, slug, {address: "ops@example.com"}) returns {success, requiresVerification} and emails a verification token. That token is never returned by any API, so verify_subscription, subscriber_preferences and unsubscribe_token only work with a token taken from the email.
status_viewer.internal reads pages by pageSlug with the API key (the organization comes from the key): status_viewer.internal.page("acme-status"). status_viewer.internal.subscribe(slug) subscribes the calling user's own contact. It works only on internal pages (400 "only available for internal status pages" otherwise) and is 403 for keys of Basic-role users.
status_viewer.sso_login(pid, {returnUrl}) is 404 for pages without SSO. On an SSO page it follows the redirect to the identity provider and returns the final page as text.
Status Hub operations
| Operation | HTTP | What it does |
| status_hub.pages.create / get / list / find(slug) | /status_pages | Create a draft page, read one, list all, find by pageSlug |
| status_hub.pages.patch(pid, changes) | GET + PUT /status_pages/{id} | Change fields safely |
| status_hub.pages.publish(pid) / unpublish(pid) | POST .../publish, .../unpublish | Publish or unpublish (returns nothing) |
| status_hub.pages.services.set_layout(pid, {service_ids, groups}) | PUT .../services/grouped | Replace the page layout |
| status_hub.pages.subscribers.list(pid, {status: "all"}) | GET .../subscribers | Subscribers, verified only by default |
| status_hub.services.create / update / delete / list | /status_pages/services | Service catalog (cursor-paged list) |
| status_hub.service_groups.create / services.add / services.remove | /status_pages/service_groups | Groups and their memberships |
| status_hub.incidents.create / updates.create / resolve | /status_pages/incidents | Incidents and their updates |
| status_hub.incidents.postmortem.create / publish | .../incidents/{id}/postmortem | Postmortems |
| status_hub.maintenance.create / patch / complete / upcoming | /status_pages/maintenance_windows | Scheduled maintenance |
| status_hub.templates.incident.* / maintenance.* / list_all() | /status_pages/*_templates | Incident and maintenance templates |
| status_hub.dashboard() / notifications.usage() | GET | Hub totals, notification quota per channel |
| status_viewer.public.resolve(alias) / page / services / incidents | GET {v3}/public/... | Anonymous viewer reads |
| status_viewer.internal.page(slug) / subscribe(slug) | {v3}/status_pages/internal/{slug} | Internal viewer with the API key |
The legacy pre-status_pages family (status_hub.legacy.*) and pages.access.set_status are dead routes (404). Each SDK's method reference lists every other operation.
Best Practices
Relevant for Anyone running status pages in production
Changing pages and maintenance
- Change pages with pages.patch and maintenance with maintenance.patch. Never PUT a partial record.
- Build the layout with set_layout, so grouped services are attached.
Notifying subscribers
- Keep notifySubscribers: false while you test, and opt in explicitly for real communications.
- Check pages.subscribers.list(pid, {status: "all"}) before announcing, so you know who will be notified.
Capacity and cleanup
- Count your pages before creating one; the plan cap is account-wide.
- Tear down in order: memberships, groups, services, then the page.
- Transfer ownership only when you mean it. You can't take it back with the same key.
Configuration Examples
Relevant for Starting templates for common setups
Example 1 — Minimal: an internal page with two services
This is the shape the SDK end-to-end scenario built and verified.
| Step | Operation |
| 1 | status_hub.pages.create({pageSlug: "acme-internal", displayName: "Acme Internal Status", visibilityType: "internal", timezoneId: <id>, customDomain: null, allowSubscriptions: false, enabledChannels: []}) |
| 2 | status_hub.services.create({name: "Checkout API", description: "Checkout API"}) and status_hub.services.create({name: "Storefront Web", description: "Storefront Web"}) |
| 3 | status_hub.service_groups.create({name: "Commerce", description: "Customer-facing"}) |
| 4 | status_hub.service_groups.services.add(gid, {services: [{serviceId: <api>, displayOrder: 1}, {serviceId: <web>, displayOrder: 2}]}) |
| 5 | status_hub.pages.services.set_layout(statusPageId, {groups: {<gid>: [<api>, <web>]}}) |
Example 2 — Typical: publish, then run an incident to resolution
| Step | Operation |
| 1 | status_hub.pages.publish(pid) |
| 2 | status_hub.incidents.create({title: "Elevated checkout latency", description: "<p>Investigating.</p>", status: 1, impact: 2, affectedServiceIds: [<api>], notifySubscribers: true}) |
| 3 | status_hub.incidents.updates.create(iid, {status: 3, message: "Fix deployed, monitoring.", notifySubscribers: true}) |
| 4 | status_hub.incidents.resolve(iid, "Latency is back to normal.", true) |
Use with caution: this notifies every verified subscriber three times.
Example 3 — Read what visitors see
status_viewer.public.resolve("acme-status") returns the organization and page slugs; status_viewer.public.page(org, slug) then returns the page's name and overallStatus. The viewer calls need no API key, and the async clients expose the same operations.
Troubleshooting
Relevant for Anyone debugging Status Hub calls
402 StatusPage.MaxTotalPages when creating a page
- The account already has its maximum number of pages (3 on the tested trial plan). Delete or reuse a page.
400 when updating a page
- pages.update was called without pageSlug or branding. Use pages.patch.
Services don't appear under their group on the page
- The layout had a group item but no {"type": "service", "groupId": ...} items. Rebuild it with set_layout.
409 "referenced by ..." when deleting
- The service is still in a group, or the group still has services. Empty the group with service_groups.services.remove first.
Subscribers were notified after a maintenance edit
- A raw maintenance.update omitted the notify and reminder flags, which reset to true. Use maintenance.patch.
complete fails with 400
- The window isn't In Progress. Run maintenance.patch(mid, {status: 2}) first.
A new subscriber is missing from the list
- Unverified subscribers are hidden by default. List with status: "all".
The public viewer returns 404
- The page isn't published. Run pages.publish(pid).
- public.page_by_id is a dead route. Use resolve(alias) and page(org, slug).
"Custom domain is not verified" from ssl.warmup
- DNS hasn't verified yet. Add the CNAME from custom_domains.create and re-check with custom_domains.check(pid, verification_id).
SSO login fails with "ASN1 corrupted data"
- samlCertificate isn't a valid base64 DER X.509 certificate. Paste the identity provider's signing certificate without PEM headers.
Related Articles
- Administration, Account, Analytics and Lookups with the AlertOps SDKs
- Workflows, Outbound Integrations and Maintenance Windows with the AlertOps SDKs
- Getting Started with the AlertOps Python SDK
- Getting Started with the AlertOps TypeScript SDK
Need help?
Email support@alertops.com or contact your AlertOps representative.