Authentication

Every /v2/* endpoint except the public invite/webhook/status routes and /health requires authentication. Three credential types are accepted; the auth middleware tries them in a fixed order.

API keys (integrations)

API keys belong to integrations and are the primary credential for monitoring tools and scripts. Four header forms are accepted, checked in this order:

# 1. Dedicated header (checked first)
X-OpsPing-Key: YOUR_API_KEY

# 2. Primary Authorization scheme
Authorization: OpsPingKey YOUR_API_KEY

# 3. OpsGenie-compatible scheme (drop-in migration)
Authorization: GenieKey YOUR_API_KEY

# 4. Legacy: raw key as a Bearer token
Authorization: Bearer YOUR_API_KEY

JWT (user sessions)

Authorization: Bearer <access-token>

Issued by POST /v2/auth/login. Access tokens are HS256 JWTs with a 15-minute default expiry (JWT_EXPIRY); refresh tokens last 30 days. Each token carries a jti (revocable via logout) and a tokenVersion — incrementing a user's tokenVersion (password reset, block, role/permission change, revoke-all) invalidates all outstanding tokens. Blocked accounts get 403 Account is blocked.

httpOnly cookie (dashboards)

Login also sets Set-Cookie: token=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900. The admin SPA authenticates with this cookie alone. Cookie auth is only attempted when no Authorization header is present — a malformed Authorization header yields 401 even with a valid cookie. The cookie is Secure, so browsers drop it over plain HTTP (except localhost).

Authorization model

LayerApplies toBehavior
Method scopesAPI keysKey must hold the scope implied by the HTTP method (read/write/delete). Missing → 403.
User permissionsJWT usersPer-resource levels none/read/write/admin. Admins always pass. Users with no explicit grants default to write on user-scoped resources (alerts, incidents, channels, users, notification-rules, saved-searches, heartbeats, schedules) and read on everything else. DELETE requires write.
Team scopingBothSingle-resource GETs and all mutations require the caller's team to match the resource's teamId (admins and unbound keys exempt). Resources with null teamId are global — any authenticated caller may mutate them. List endpoints filter to own-team + global; ?allTeams=true opts out (any authenticated caller, not just admins).

Rate limits

LimiterLimitKeying
General (per-router)120 req/min authenticated, 60 req/min anonymousSHA-256 of the Authorization header, else client IP. Fixed 60s window, DynamoDB counter, fail-open on DB error.
Registration3 per IP per hourPOST /v2/auth/register only; only enforced when a real proxy IP header is present.
Inbound webhooks300 req/minPer webhook URL token.
Channel resend1 per 30sPer channel verification code resend.
Quirks: identity is the Authorization header only — requests authed solely via X-OpsPing-Key or cookie fall into the 60/min anonymous IP bucket. 429 responses are {"message":"Rate limit exceeded","code":"RATE_LIMIT_EXCEEDED","took":0,"requestId":"…"} with no Retry-After or X-RateLimit-* headers. Fixed windows allow up to 2× bursts at window edges. RATE_LIMIT_DISABLED=1 disables the general limiter server-side. Rate limiting is per-router; a few routers (status page admin, export, team roles) have none.

Conventions

Base URL

https://api.ops-ping.com        # hosted
https://<your-host>            # private deployment

Response envelope

Most successful responses use one of three shapes:

// Single resource — respond()
{ "data": { … }, "took": 0.012, "requestId": "uuid" }

// List — respondList()
{ "data": [ … ], "totalCount": 42, "took": 0.012, "requestId": "uuid",
  "nextCursor": "…" }            // only when a cursor exists

// 202 Accepted — respondAccepted() (alert mutations)
{ "result": "Request will be processed", "took": 0, "requestId": "uuid" }

took is wall-clock seconds for the request (3 decimals); requestId is a fresh UUID per response (not correlated to any request header).

Not universal. Many handlers bypass the envelope: most 404s are bare {"message":"…"} (no code, took, or requestId); the channels API returns raw {"ok":true,…} shapes; the global error handler returns 500 {"message":<raw error>,"took":0,"requestId":"…"}. Clients must tolerate all of these. Creates return 200 in most routers (not 201); a few (rotations, routing rules, invites, report schedules, team roles) return 201.

Errors

// Structured error (respondError)
{ "message": "Invalid pagination cursor", "code": "PAGINATION_CURSOR_INVALID", "took": 0, "requestId": "uuid" }

// Validation failure (zod)
{ "message": "Invalid request: message: String must contain at least 1 character(s); …", "code": "BAD_REQUEST", … }

// Common codes: UNAUTHORIZED (401), FORBIDDEN (403), BAD_REQUEST / VALIDATION (400),
// NOT_FOUND (404), RATE_LIMIT_EXCEEDED (429), PAGINATION_CURSOR_INVALID (400)

Pagination

List endpoints accept ?limit= (default 50, max 200 on most; alerts: default 20, max 100) and ?cursor= (base64url-encoded DynamoDB key, returned as nextCursor). An undecodable cursor → 400 PAGINATION_CURSOR_INVALID.

Pagination is approximate. Lists scan without a server-side limit, filter in memory (team scoping), then slice. nextCursor is only emitted when the underlying scan itself paginates (~1 MB of data), so below that threshold items beyond limit may be unreachable and totalCount can be a true total, a filtered count, or just the page size depending on the endpoint. Treat lists as "fetch with a high limit". The alerts list has no cursor at all.

Identifiers

Resources use UUID ids. Alerts additionally have a numeric tinyId and most alert routes accept either as :identifier. tinyId is a random number 0–99999 with no uniqueness check — always address alerts by UUID.

Timestamps & bodies

All timestamps are UTC ISO-8601 strings. Request bodies are JSON, max 1 MB. Several routers validate bodies with zod schemas (noted per endpoint); others accept unvalidated JSON — send only documented fields. All dates in responses are strings; null means unset.

OpsGenie envelope

Alert create/mutate responses follow the OpsGenie convention of 202 Accepted + requestId. OpsPing processes alert creation synchronously (dedup, escalation, and notification dispatch run immediately, dispatch fire-and-forget) — but, like OpsGenie, the create response does not include the alert ID. Look the alert up by alias via the list endpoint.

Alerts

The core resource. Alert objects look like this (JSON-stored fields are parsed back to objects in responses):

{
  "id": "uuid", "tinyId": 12345, "alias": "cpu-prod-web-3",
  "message": "CPU usage > 90% on prod-web-3",
  "status": "open",              // open | acknowledged | closed
  "acknowledged": false, "isSeen": false,
  "readBy": [{ "userId": "…", "userName": "…", "readAt": "…" }],
  "tags": ["cpu"], "visibleTo": null,
  "snoozed": false, "snoozedUntil": null,
  "count": 1,                    // dedup hit counter
  "lastOccurredAt": "…", "createdAt": "…", "updatedAt": "…",
  "source": "Datadog", "owner": null, "priority": "P2",
  "responders": [{ "id": "…", "type": "schedule", "name": "Primary On-Call" }],
  "entity": "", "description": "", "details": {}, "actions": []
}
POST/v2/alertsAPI key or JWT · write

Create an alert. Validated by the CreateAlertBody zod schema.

FieldTypeNotes
messagestring 1–130Required.
aliasstring ≤512Dedup key: an open alert with the same alias is not recreated — its count increments and message/description/lastOccurredAt update. Notification rules are not re-dispatched on dedup.
priorityP1–P5Default P3.
descriptionstring ≤15000
source / entity / userstring ≤100/512/100
tagsstring[]Max 20, each ≤50 chars.
detailsrecord<string,string>Custom key/value payload.
respondersobject[] ≤50{ type: "user"|"team"|"schedule"|"escalation", id?, name?, username? }
skipEscalationbooleanSkip the escalation engine; falls back to a team-scoped push broadcast.
notestring ≤25000Accepted but never persisted — dead field.

Always 202 on success, in three shapes: dedup/no-policy → {"result":"Request will be processed",…}; broadcast/skipEscalation → {"data":{"result":"created","escalated":false,"mode":"broadcast"},…}; escalated → {"data":{"result":"created","escalated":true,…},…}. Every shape also carries alertId (the created alert's id, or the existing alert's id on dedup) — top-level in the first shape, under data in the other two. After persisting, OpsPing fire-and-forget runs outbound webhooks, notification-rule dispatch, and the escalation engine. Routing rules may stamp a team when the caller has none.

curl -X POST https://api.ops-ping.com/v2/alerts \
  -H "Authorization: OpsPingKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "CPU usage > 90% on prod-web-3",
    "alias": "cpu-prod-web-3",
    "priority": "P2",
    "source": "Datadog",
    "tags": ["cpu", "production"],
    "details": { "cpu_pct": "94.2", "host": "prod-web-3" },
    "responders": [{ "type": "schedule", "name": "Primary On-Call" }]
  }'
GET/v2/alertsread

List alerts, newest first. Query params: limit (default 20, max 100), query (case-insensitive substring over message/description/source/tags — not the OpsGenie query language), status, allTeams=true. Non-admin callers see own-team + global alerts. Response: {"data": Alert[], "totalCount": <page size>, …}. No cursor.

curl "https://api.ops-ping.com/v2/alerts?status=open&limit=50" \
  -H "Authorization: OpsPingKey YOUR_API_KEY"
GET/v2/alerts/countread

Alert count, team-scoped like the list (allTeams=true honored; no status filter). Response: {"data":{"count":42},…}.

GET/v2/alerts/:identifierread · team-checked

Get one alert by UUID or tinyId (tinyId lookup is a full-table scan fallback; tinyIds are not guaranteed unique). 404 {"message":"Alert not found"}.

Lifecycle actions

All lifecycle mutations return 202 {"result":"Request will be processed",…} (after the work is already done), write an activity entry, and drive the escalation engine in the background. All enforce team access.

EndpointEffectNotes
POST /:id/acknowledgestatus=acknowledged, stops escalation
POST /:id/unacknowledgeBack to open, resumes escalation
POST /:id/closestatus=closed, completes escalationLeaves acknowledged untouched — a closed alert can remain un-acked.
POST /:id/reopenclosed → open, acknowledged=false, resumes escalation409 unless the alert is closed.
POST /:id/snoozeBody {"endTime":"<ISO>"}; sets snoozed/snoozedUntilendTime not validated; a missing body throws a 500. Writes no activity entry.
POST /:id/unsnoozeClears snooze409 if not snoozed.
POST /:id/assignSets ownerBody {"user":"…","note":"…"}empty body is accepted (owner defaults to "api"). Unvalidated.
POST /:id/escalateImmediately fires the next escalation step200 {"data":{"escalated":true|false,"reason":"…","recipient?":…}} — fires the real engine.
POST /:id/readAppends a read receipt to readBy (idempotent per user)Intended to be JWT-only, but API keys pass too and record receipts as the integration.
POST /:id/process-escalationAdvances the escalation engine for this alert200 with the engine result. Authenticated despite a stale "no auth" code comment.

Notes, activities, details, responders, tags

EndpointDescription
POST /:id/notesAdd a note. Body {"note":"…","user?":"…"} (unvalidated; user defaults "api"). 202.
GET /:id/notesList notes, newest first: {"data":[{"id","note","user","createdAt"}], "totalCount",…}.
GET /:id/activitiesActivity timeline, newest first, unpaginated: {"data":{"activities":[{"id","type","user","createdAt","metadata"}]},…} (nested under data.activities, not a bare list).
POST /:id/detailsShallow-merge {"details":{…}} into custom details. 200 with the merged object.
POST /:id/respondersAdd responders: {"responders":[{"type","name?","id?"}]}. 200 {"data":{"result":"added","count":n}}.
GET /:id/recipientsBare array of user IDs actually notified (harvested from escalation activity metadata): {"data":["userId",…]}.
POST /:id/tagsUnion-merge {"tags":["a","b"]}. 200 with the merged array. Empty array is a no-op (despite the "non-empty" error text).
DELETE /:id/tags?tag=<value>Remove one tag (400 without tag). 200 with remaining tags.

Attachments

Presigned-S3 file attachments per alert (two-step upload: mint → PUT → complete). Limits: 10 MB max file size, 10 attachments per alert; pending mints older than 15 minutes never consumed the upload URL and don't count against the quota. All four endpoints team-checked, and all return 503 ATTACHMENTS_DISABLED when the deployment has no attachments bucket configured.

EndpointDescription
POST /:id/attachmentsMint a presigned PUT. Body {"fileName","contentType"?,"size"} (size in bytes; 400 if missing or >10 MB; 400 if the alert already has 10 live attachments). → 201 {"data":{"id","uploadUrl","expiresIn":900}} — PUT the file to uploadUrl within 15 minutes.
POST /:id/attachments/:attachmentId/completeFinalize: server runs S3 HeadObject and verifies the real size (an oversize object is deleted immediately + 400; an object that was never uploaded → 400). Marks the row uploaded, writes an attachment-added activity entry. → 200 {"data":{"result":"uploaded"}}.
GET /:id/attachmentsUploaded attachments only: {"data":[{"id","fileName","contentType","size","uploadedBy","createdAt","downloadUrl"}]} — each row carries a fresh presigned download URL valid 1 hour.
DELETE /:id/attachments/:attachmentIdDeletes the S3 object and the row. → 200 {"data":{"result":"deleted"}}.
curl -X POST https://api.ops-ping.com/v2/alerts/ALERT_ID/attachments \
  -H "Authorization: OpsPingKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fileName":"heap-dump.png","contentType":"image/png","size":482133}'
# → uploadUrl … then:
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/png" --data-binary @heap-dump.png
curl -X POST https://api.ops-ping.com/v2/alerts/ALERT_ID/attachments/ATTACHMENT_ID/complete \
  -H "Authorization: OpsPingKey YOUR_API_KEY"
POST/v2/alerts/bulkwrite

Bulk-mutate up to 100 alerts. Body: {"ids":["…"],"action":"acknowledge"|"close"|"snooze"|"assign","snoozeUntil"?,"assignUser"?,"note"?}. Response 200 {"data":{"processed":n,"results":[{"id","success","error"?}]}} — per-id errors include not-found, forbidden, already-closed, snoozeUntil required. Note: bulk actions write no activity entries (unlike the single endpoints) and note is ignored.

DELETE/v2/alerts/:identifierAPI key: delete scope · JWT: write

Hard-deletes the alert plus its activities, notes, and responders. 200 {"data":{"result":"deleted"}}. Orphaned behind: the alias dedup pointer (self-healing), escalation state, and pending notification-rule dispatches.

User Auth & MFA

User session endpoints under /v2/auth. None accept API-key auth as the subject (it is user auth), and all success responses use the standard envelope.

POST/v2/auth/loginpublic

Body {"email","password"}. 200 {"data":{"token","refreshToken","tokenVersion","user":{"id","email","name","role","teamId"}}} + httpOnly cookie. With MFA enabled: {"data":{"mfaRequired":true,"challengeToken"}} instead. 401 Invalid email or password (non-enumerating), 403 Account is blocked.

curl -X POST https://api.ops-ping.com/v2/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"oncall@example.com","password":"…"}'
EndpointDescription
POST /v2/auth/registerBody {"email","password","fullName","role"?} → 200 {"data":{"id","result":"created"}}. Rate-limited 3/IP/hour. role is silently ignored (always "user"); no password-strength check. 409 User already exists — leaks account existence.
POST /v2/auth/refreshBody {"refreshToken"}{"data":{"token","refreshToken","tokenVersion"}} (no user object). Rotates the pair, but old refresh tokens remain valid until expiry unless tokenVersion changed. Errors: 401 REFRESH_INVALID/SESSION_REVOKED, 403 USER_BLOCKED.
GET /v2/auth/meTwo shapes: API key → {"data":{"id","type":"api_key","role":"api_key","teamId"}}; JWT → {"data":{"id","email","name","role","teamId","hasCompletedOnboarding"?,"permissions"?}} (role/teamId from JWT claims).
POST /v2/auth/logoutRevokes the current token's jti, clears the cookie → {"data":{"result":"logged_out"}}.
POST /v2/auth/revoke-allJWT only. Increments tokenVersion (kills all sessions) and blacklists the caller → {"data":{"result":"revoked_all","tokenVersion"}}.
POST /v2/auth/forgotBody {"email"}. Always 200 with a generic message (anti-enumeration). Emails a 1-hour reset link when the email provider is configured.
POST /v2/auth/resetBody {"token","newPassword"} (min 8 chars) → {"data":{"success":true}}. Single-use token; increments tokenVersion (all sessions killed). 401 RESET_INVALID for unknown/used/expired.

MFA (TOTP)

EndpointDescription
POST /v2/auth/mfa/enrollJWT. → {"data":{"secret","otpauthUri"}} (issuer OpsPing). Nothing is persisted until verify.
POST /v2/auth/mfa/verifyBody {"secret","code"} (echo the enroll secret) → {"data":{"mfaEnabled":true,"backupCodes":[…×10]}}. Backup codes are shown exactly once; only hashes are stored. Secret stored AES-256-GCM encrypted.
POST /v2/auth/mfa/disableDisables MFA — no password or TOTP confirmation required.
POST /v2/auth/login/mfaBody {"challengeToken","code"} → full login response. The challenge token is valid for ~15 minutes (a code comment's "2m" constant is dead).
POST /v2/auth/login/mfa/backupSame shape with a backup code; codes are single-use.

SSO (OIDC)

EndpointDescription
GET /v2/auth/sso/:teamSlugPublic. 302 redirect to the team's IdP (PKCE S256). :teamSlug is the team name lowercased. 400 SSO_NOT_CONFIGURED if the team has no OIDC config.
GET /v2/auth/sso/callbackPublic IdP redirect target. On success 302 → <app-url>sso-callback#token=…&refreshToken=…&tokenVersion=…&email=… (default app URL opsping://). Just-in-time user provisioning. Note: SSO logins bypass MFA even for users with it enabled.

Users, Teams & Invites

Users — /v2/users

GET/v2/usersread

List users. Query: limit (50/200), cursor, q (case-insensitive substring on name/username). Items: {id, username, fullName, role, blocked, verified, timezone, locale, createdAt, notifyPush, notifyEmail, notifySms, notifyOnP1Only, notifyQuietHoursStart/End, showSensitiveContent, hasCompletedOnboarding, permissions, mutedUntil}. Subject to the approximate-pagination caveat; mfaEnabled only appears on the single-get.

curl "https://api.ops-ping.com/v2/users?q=alice" \
  -H "Authorization: OpsPingKey YOUR_API_KEY"
EndpointDescription
GET /v2/users/:idSingle user (adds mfaEnabled). 404 {"message":"User not found"}.
POST /v2/usersValidated: username 1–100, fullName 1–200 (required); role?/timezone?/locale?/password? (min 8). → 200 {"data":{"id","result":"created"}}. role honored only for admin callers. Any authenticated user can create users (users is a user-scoped resource with default write). No username-uniqueness check.
PATCH /v2/users/:idAdmin or self. Whitelisted fields only (profile, notification prefs, mutedUntil, permissions, …); non-admins cannot set role/blocked/permissions — but can set verified on themselves. Changing role/blocked/permissions increments tokenVersion (all sessions killed). Migration quirk: patching a pre-RBAC user without a permissions field grants them the full admin permission matrix.
DELETE /v2/users/:idAdmin or self. Deletes the user record only — push tokens, memberships, channels, and notification rules are orphaned.
POST /v2/users/:id/mfa/resetAdmin JWT only. Clears MFA + kills sessions, writes an audit entry. 200 even if MFA wasn't enabled (mfa_not_enabled).
GET /v2/users/:id/escalationsEscalation policies whose rules target this user.
GET /v2/users/:id/schedulesBug: returns all schedules in the system, not the user's — :id only gates the 404.
GET /v2/users/:id/teamsTeams the user belongs to (correctly user-scoped).

Teams — /v2/teams

EndpointDescription
GET /v2/teamsTeams with members:[{id,name,role}]. Non-admin callers see only their own team (a teamless caller sees an empty list); no allTeams param. Query: limit, cursor.
POST /v2/teamsBody {"name","description"?,"members"?[{"userId","role"}]} (unvalidated) → 200 {"data":{"id","result":"created"}}. JWT non-admins need explicit write on teams (default is read → 403). No duplicate-name check; member userIds not validated.
GET /v2/teams/:idTeam + members. Team-checked. 404 raw {"message"}.
PATCH /v2/teams/:id{"name"?,"description"?} only. Team-checked. Empty body → 200 "updated" no-op.
DELETE /v2/teams/:idDeletes members then the team (non-atomic). Resources referencing the team (alerts, schedules, roles) are untouched.
GET /v2/teams/:id/members{"data":[{"userId","role","joinedAt"}],…}. Team-checked.
POST /v2/teams/:id/members{"userId","role"?}upsert: re-adding overwrites role. No user-existence check.
DELETE /v2/teams/:id/members/:userIdRemoves a member. Always 200, even if team/member doesn't exist.

Team roles — /v2/teams/:teamId/roles

Custom per-team role definitions: {id, name, teamId, permissions: Record<string,boolean>, createdAt}. Routes: GET /, POST / (201), GET|PATCH|DELETE /:roleId. Team-checked — but against the role's stored team, so the path :teamId is decorative on the single-role routes. Note: this router has no scope/permission middleware — any authenticated caller (even a read-scoped API key) can write roles for their team.

Invites — /v2/invites

EndpointDescription
POST /v2/invitesValidated: {"email","role":"admin"|"user"|"stakeholder","permissions"?,"teamId"?} → 201 {"data":{"token","email","expiresAt"}} (7-day expiry, single-use). 409 if the email is registered. Any authenticated user can create invites with any role — no admin gate. Email delivery is a stub: read the token from the response and share /register?token=… yourself.
GET /v2/invitesPending invites, newest first. Expired-but-unused invites still appear.
GET /v2/invites/:tokenPublic. Invite details; 404 NOT_FOUND, 410 INVITE_USED (used or expired).
POST /v2/invites/:token/acceptPublic. Body {"password"(min 8),"fullName"} → 201 {"data":{"id","email","role"}}. Creates the user (+ team membership if the invite has a team). No auto-login.
DELETE /v2/invites/:tokenRevoke (hard delete; works on used/expired too).

Schedules & PTO

Schedules — /v2/schedules

GET/v2/schedulesread

List schedules with rotations: {id, name, description, timezone, enabled, createdAt, rotations:[{id, name, startDate, endDate, type, length, participants, timeRestriction}]}. Team-scoped (own team + global; ?allTeams=true opts out). One extra query per schedule (N+1).

POST/v2/scheduleswrite

Create a schedule with optional rotations. Unvalidated body: name, description?, timezone? (default UTC), enabled? (default true), rotations?[{name?, startDate, endDate?, type, length?, participants?[], timeRestriction?}]. → 200 {"data":{"id","result":"created"}}. JWT-created schedules are always global (teamId is only stamped from API-key context) — and global resources are mutable by any authenticated user.

curl -X POST https://api.ops-ping.com/v2/schedules \
  -H "Authorization: OpsPingKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Primary On-Call",
    "timezone": "America/New_York",
    "rotations": [{
      "name": "Weekly",
      "type": "weekly",
      "startDate": "2026-08-03T00:00:00Z",
      "participants": [{ "type": "user", "name": "alice" }, { "type": "user", "name": "bob" }]
    }]
  }'
EndpointDescription
GET /v2/schedules/:idSchedule + rotations + overrides:[{id,userId,userName,startTime,endTime}]. Optional ?start=&end= (ISO) narrows overrides to the window. Team-checked.
PATCH /v2/schedules/:idUpdate metadata fields present in the body. If rotations is provided, all existing rotations are deleted and replaced wholesale (overrides untouched). Empty body → 200 no-op.
DELETE /v2/schedules/:idCascades to rotations and overrides.

Rotations & overrides

EndpointDescription
GET /v2/schedules/:id/rotationsList rotations (totalCount envelope).
POST /v2/schedules/:id/rotationsAdd a rotation → 201 {"data":{"id","result":"created"}} (the one 201 in this router). Fields: name?, startDate?, endDate?, type? (daily default; any string stored), length? (default 1), participants?[{id,name?,type?}], timeRestriction?{start,end}.
GET /v2/schedules/:id/rotations/:ridSingle rotation. 404 Rotation not found.
PATCH /v2/schedules/:id/rotations/:ridPartial update; only present fields change (timeRestriction can be nulled).
DELETE /v2/schedules/:id/rotations/:ridDelete one rotation.
POST /v2/schedules/:id/overridesBody {"userId","userName"?,"startTime","endTime","reason"?} (times unvalidated, no order/conflict check) → 200 {"data":{"id","result":"override created"}}. Fire-and-forget pushes "Your on-call shift was overridden" to affected users.
DELETE /v2/schedules/:id/overrides/:oidRemoves an override → 200 override removed. Never 404s, even when the override doesn't exist.

On-call queries

GET/v2/schedules/:id/on-callsread · team-checked

Who is on call. Optional ?at=<ISO> (default now). Response: {"data":{"parent":{"id","name"},"onCallRecipients":[{"name","startTime"?,"endTime"?}],"nextOnCallRecipients"?:[…],"source":"override"|"rotation"|"none"}}. An active override wins and the nextOnCallRecipients key is then absent entirely — handle both shapes.

curl "https://api.ops-ping.com/v2/schedules/SCHEDULE_ID/on-calls" \
  -H "Authorization: OpsPingKey YOUR_API_KEY"
EndpointDescription
GET /v2/schedules/:id/timelinePer-date on-call view. Requires ?start=&end= (ISO; 400 otherwise), optional ?interval=day|week|month (default day). → {"data":{"timeline":[{"date","rotationName"?,"userName"?,"isOverride"}],"start","end","interval"}}. Overrides win per date; rotations evaluated at noon UTC. No range cap.
GET /v2/schedules/:id/shiftsComputed shift list. Requires ?start=&end= (400 if end ≤ start). → {"data":{"shifts":[{"id","startTime","endTime","userId","userName","isOverride","overriddenUserName","rotationId"?}],…,"timezone"}}. Rotation shifts split around overrides (rot:…:pre/:post ids; overrides are ovr:<id>).
GET /v2/schedules/:id/icaltext/calendar download (oncall-<id>.ics), one VEVENT per shift. Fixed window: now → now+30 days, rotation shifts only — overrides are not included.

PTO requests — /v2/pto-requests

EndpointDescription
GET /v2/pto-requestsList, newest first: {id,userId,userName,startDate,endDate,reason,status,createdAt,approvedBy/At,deniedBy/At}. Query: ?userId= or ?status=pending|approved|denied (userId wins if both). Any authenticated caller can list all requests.
POST /v2/pto-requestsBody {"startDate","endDate","reason"?} (400 if missing or end ≤ start) → 200 {"data":{"id","status":"pending","result":"created"}}. Owner = caller.
PATCH /v2/pto-requests/:id/approveAdmins, or lead/admin member of any team (not necessarily the requester's). 400 unless pending.
PATCH /v2/pto-requests/:id/denySame authz as approve.
DELETE /v2/pto-requests/:idOwner or admin; 400 unless pending. Hard-deletes (no "cancelled" record kept).

Escalations

Escalation policies: ordered rules that notify a schedule/user/team after a delay while an alert stays un-acked. Validated by local zod schemas (400 VALIDATION on failure). Regular JWT users default to read-only (escalations is not a user-scoped resource); writes need an explicit grant.

POST/v2/escalationswrite

Body: name (required), description?, teamId?, priorities? (subset of P1–P5; null = all), isDefault?, repeatInterval? (≥0), repeatCount? (≥1), rules?[{condition?, notifyType?: default|all|random|admins, delayMinutes?, recipientType: schedule|user|team, recipientId, recipientName?}]. → 200 {"data":{"id","result":"created"}}. isDefault:true clears the flag on every other escalation globally (cross-team). Recipients are not validated.

curl -X POST https://api.ops-ping.com/v2/escalations \
  -H "Authorization: OpsPingKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Backend Escalation",
    "rules": [
      { "delayMinutes": 0,  "recipientType": "schedule", "recipientId": "SCHEDULE_ID" },
      { "delayMinutes": 15, "recipientType": "user",     "recipientId": "USER_ID", "notifyType": "all" }
    ]
  }'
EndpointDescription
GET /v2/escalationsList (?allTeams=true opts out of team filtering). Items: {id,name,description,teamId,priorities,isDefault,repeatInterval,repeatCount,closeAlertAfterAll,createdAt}. No pagination.
GET /v2/escalations/:idDetail incl. ordered rules:[{id,condition,notifyType,delayMinutes,recipientType,recipientId,recipientName,order}]. Also returns resetRecipientStates (always false — not settable via the API).
PATCH /v2/escalations/:idPartial update. If rules is present (even []), the entire ruleset is replaced — delete all, rewrite (non-atomic). Retargeting teamId requires access to both teams.
DELETE /v2/escalations/:idDeletes rules then the policy. No check for in-flight alert references.

Incidents

Declared incidents (OpsPing-native; not part of the OpsGenie compat surface). Regular JWT users have default write.

EndpointDescription
GET /v2/incidentsList: {id,name,status,message,description,teamId,createdAt,priority,tags,impactedServices,statusPageEntry}. Query: limit, cursor, allTeams=true.
POST /v2/incidentsBody (unvalidated): name, message? (defaults to name), description?, priority?, tags?, details?, responders?, impactedServices?, statusPageEntry?, notifyStakeholders?. → 200 {"data":{"id","result":"created"}}. Status forced open; JWT-created incidents are global.
GET /v2/incidents/:idDetail (adds updatedAt, details, responders, notifyStakeholders). Team-checked.
PATCH /v2/incidents/:idUpdates status/priority/tags/details/responders/impactedServices/statusPageEntry/notifyStakeholders only — name/message/description are silently ignored.
DELETE /v2/incidents/:idTeam-checked. 200 {"data":{"result":"deleted"}}.

Postmortems

Incident write-ups: {id,incidentId,summary,rootCause,timeline:[{time,description}],actionItems:[{description,assignee?,status:pending|in_progress|done}],createdAt,updatedAt}. Access is scoped through the parent incident (team + tenant checked against the incident; if the incident was deleted, the tenant check falls back to the postmortem's own tenant). Gated under the incidents permission — regular users have default write.

POST/v2/postmortems/generatewrite

Generate (or refresh) a postmortem from an incident's audit trail. Body {"incidentId"} (400 if missing; 404 if the incident doesn't exist — tenant/team checked via the incident). The timeline is assembled from the incident's audit entries, oldest first ("<action> by <userName>" per entry, full trail — never truncated). Upsert: an existing postmortem keeps its user-edited summary/rootCause/actionItems — only the timeline refreshes (regenerated:true); a first-time generate writes a stub summary (incident name/status/priority/timestamps) and returns regenerated:false. 200, standard envelope.

curl -X POST https://api.ops-ping.com/v2/postmortems/generate \
  -H "Authorization: OpsPingKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"incidentId":"INCIDENT_ID"}'
EndpointDescription
GET /v2/postmortemsList, team-scoped via the parent incident (own team + global incidents). Optional ?teamId= narrows to that team's incidents.
POST /v2/postmortemsCreate manually. Validated (incidentId + summary required; 400 VALIDATION); incident must exist (404) and pass team/tenant checks. → 201 {"data":{"id",…body,"createdAt"}}.
GET /v2/postmortems/:idSingle postmortem. 404 raw {"message"}.
PATCH /v2/postmortems/:idUpdate summary/rootCause/timeline/actionItems (validated; incidentId immutable). → 200 {"data":{"id","result":"updated"}}.
DELETE /v2/postmortems/:idHard delete. → 200 {"data":{"result":"deleted"}}.
GET /v2/postmortems/:id/exportDownload as Markdown (?format=md, default — text/markdown attachment) or PDF (?format=pdfapplication/pdf attachment). Filename postmortem-<incidentId>.md|.pdf.

Services

Service catalog entries: {id,name,description,status,teamId,visibility,tags,createdAt,updatedAt} (status default ok, visibility default internal). Regular JWT users are read-only by default (services is not user-scoped); writes need an explicit grant or admin. Standard CRUD: GET /, POST /, GET /:id, PATCH /:id, DELETE /:id.

Quirk: PATCH /v2/services/:id never 404s — patching a nonexistent ID is a silent no-op returning {"data":{"result":"updated"}} (deliberate legacy behavior, no id in the response), and empty-string name/status values are ignored.

Heartbeats

Dead-man's-switch monitoring: your job pings OpsPing; if a ping isn't received within the configured interval, OpsPing opens an alert. name is the primary key.

POST/v2/heartbeats/:name/pingAPI key or JWT · write scope

Ping a heartbeat. Authentication is required — send an API key with write scope. Unknown names are auto-created with defaults (interval:60 minutes, P3) and return {"data":{"result":"created and pinged"}}; existing heartbeats return {"data":{"result":"pinged"}}. Pinging an expired heartbeat auto-closes its expiry alert ("heartbeat-recovery" activity).

curl -X POST https://api.ops-ping.com/v2/heartbeats/nightly-backup/ping \
  -H "Authorization: OpsPingKey YOUR_API_KEY"
EndpointDescription
POST /v2/heartbeatsCreate (unvalidated body): name, description?, interval, intervalUnit (free-form string — use minutes|hours|days), enabled? (default true), alertMessage?, alertTags?, alertPriority? (default P3), serviceId?. → 200 {"data":{"result":"created"}}; 409 if the name exists.
GET /v2/heartbeatsList: {name,description,interval,intervalUnit,enabled,expired,lastPing,serviceId,alertMessage,alertTags,alertPriority}. Query: limit (50/200), cursor, allTeams=true.
GET /v2/heartbeats/:nameSingle heartbeat. Team-checked. 404 Heartbeat not found.
PATCH /v2/heartbeats/:namePartial update of any create field except name (no rename — name is the key). Empty body → 200 no-op.
DELETE /v2/heartbeats/:nameTeam-checked; API keys need delete scope.

Maintenance Windows

Time windows during which the heartbeat-expiry job suppresses alert creation. A window applies when enabled and startTime ≤ now ≤ endTime, and is global when both teamId and serviceId are null, otherwise scoped to that team/service. Regular JWT users default to read-only.

EndpointDescription
GET /v2/maintenance-windowsList: {id,name,description,startTime,endTime,serviceId,enabled,teamId,createdAt}. Query: limit, cursor, allTeams=true.
POST /v2/maintenance-windowsBody: name, startTime, endTime (required, ISO; stored unvalidated — end-before-start accepted), description?, serviceId?, suppressAlerts?. → 200 {"data":{"id","result":"created"}}.
GET /v2/maintenance-windows/:idSingle window. Team-checked.
PATCH /v2/maintenance-windows/:idPartial update; accepts enabled (see quirk below).
DELETE /v2/maintenance-windows/:idTeam-checked; API keys need delete scope.
Field-name trap: on create, the stored enabled flag is read from suppressAlerts (default true) — sending {"enabled":false} is silently ignored; send {"suppressAlerts":false} to create a disabled window. PATCH uses enabled. Also: despite the name, suppression only affects heartbeat-expiry alerts — it is not a general notification mute.

Channels

Per-user SMS / voice / email delivery targets used by notification rules. Channel object: {id,userId,type,value,label?,verified,lastTestAt?,lastTestResult?,createdAt,updatedAt}. This router answers with raw {"ok":true,…} shapes — no took/requestId envelope. Admins can operate on another user's channels with ?userId=.

POST/v2/channelsJWT intended · write

Create a channel and send a 6-digit verification code (10-min TTL). Validated: type: "sms"|"voice"|"email", value (E.164 +1234567890 for sms/voice, valid email otherwise), label? ≤120. Response 200: provider configured → {"ok":true,"channel":{…},"codeSent":true}; provider not configured (dev) → {"ok":true,"channel":{…},"verificationCode":"123456"}. Creating channels repeatedly is not throttled — each create sends a real SMS/voice/email when providers are live.

curl -X POST https://api.ops-ping.com/v2/channels \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{"type":"sms","value":"+15551234567","label":"Personal cell"}'
EndpointDescription
GET /v2/channelsOwn channels, oldest first: {"ok":true,"channels":[…],"providerDisabled":bool}.
POST /v2/channels/:id/verifyBody {"code":"123456"} (6 digits). Idempotent; success consumes the code. 401 INVALID_CODE (wrong/expired).
POST /v2/channels/:id/resendNew code; invalidates the previous one. 30-second throttle (429 RATE_LIMIT_EXCEEDED) — but the first resend right after create is never throttled.
POST /v2/channels/:id/testReal provider send (verified channels only, else 403 UNVERIFIED). Always 200: {"ok":true|false,"reason"?,"lastTestAt","lastTestResult":"ok"|"failed"} — a provider failure is not a 4xx/5xx.
DELETE /v2/channels/:idDeletes the channel and cascades: the channel ID is removed from every notification rule's action.channelIds.

Notification Rules

Per-user rules that route matching alerts to channels, with optional delay and repeat. Rule object: {id,userId,name,enabled,criteria,action,repeat,order,createdAt,updatedAt}. Dispatch runs on alert create (not dedup); delayed/repeat deliveries are durable and re-check the alert at fire time. Every dispatch outcome is written to the audit log (?entityType=notification-rule).

POST/v2/notification-rulesJWT intended · write

Create a rule (validated). → 200 {"data":{"id","result":"created"}}. order is server-assigned (append); change it only via /reorder.

FieldTypeNotes
namestring 1–120Required.
enabledbooleanDefault true.
criteria.prioritiesP1–P5[]Default [].
criteria.sources / tagsstring[]Default [].
criteria.actionTypesenum[]create-alert|acknowledged|closed|assigned.
criteria.criteriaTypeenummatch-all (default) | match-any — applies to priority/tags/source/actionType only.
criteria.teamIdsstring[]AND-ed restriction. Engine-only: skipped by the dry-run endpoint.
criteria.timeWindows{start,end}[]24h HH:MM, evaluated in the rule owner's timezone. AND-ed.
criteria.scheduleIdstringOn-call gate: rule only fires when the owner is on call (override wins, else rotation math).
action.channelIdsstring[]Verified channels to notify.
action.delayMinsint 0–1440Durable delayed delivery.
repeat.enabled / intervalSec / maxRepeatsbool / int / int?Default false / 900 / repeat until the alert is acked or closed.
curl -X POST https://api.ops-ping.com/v2/notification-rules \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Page me on P1",
    "criteria": { "priorities": ["P1"] },
    "action":  { "channelIds": ["CHANNEL_ID"], "delayMins": 0 },
    "repeat":  { "enabled": true, "intervalSec": 300 }
  }'
EndpointDescription
GET /v2/notification-rulesOwn rules sorted by order. ?userId= (admin) to inspect another user.
GET /v2/notification-rules/:idSingle rule. 404 raw {"message"}.
PATCH /v2/notification-rules/:idPartial — but replace, not merge: a provided criteria/action/repeat object is re-defaulted, wiping omitted subfields. Send complete sub-objects.
DELETE /v2/notification-rules/:idDelete. Remaining rules keep their order values (no renumbering).
POST /v2/notification-rules/reorderBody {"ids":[…]} — must name every rule exactly once (all-or-nothing validation). 200 with the full reordered list.
POST /v2/notification-rules/:id/matchDry-run a rule against a hypothetical alert: {"priority"?,"tags"?,"source"?,"actionType"?}{"data":{"match":true}} or {"match":false,"reasons":[…]} ("reason":"rule-disabled", singular, when disabled). Uses the exact engine matcher — except the teamIds gate, which is skipped here.
Delivery prefs suppress dispatch: a user's mutedUntil suppresses everything including P1; notifyOnP1Only and quiet hours suppress non-P1 only. Unverified channels are skipped (skipped-unverified in the audit log).

Alert Policies

Server-side preprocessing applied to every alert on create — including alerts created via inbound webhooks and email (the exact create path is shared). Policies are ordered, first-match: the first enabled policy whose match block fits the incoming alert wins; evaluation stops there. Match fields are AND-ed; an empty match matches every alert. Scope: policies are per-tenant; a policy may bind to a teamId (null = tenant-global), and the single-resource routes are team-checked — the list is tenant-wide, not team-filtered. Every mutation writes an audit entry (alert_policy.create|update|delete|reorder; the dry-run writes nothing). Not supported yet: time-based conditions and payload enrichment. Gated under the escalations permission — regular JWT users default to read-only.

POST/v2/alert-policieswrite

Create a policy (validated, 400 VALIDATION). → 200 {"data":{"id","result":"created"}}.

FieldTypeNotes
namestring 1–200Required.
teamIdstring | nullBind to a team; null = tenant-global. Defaults to a team-bound API key's team.
enabledbooleanDefault true. Disabled policies are skipped by the matcher.
orderint ≥0Default 0; evaluation order (change it via /reorder).
match.prioritiesP1–P5[]Alert priority must be in the list.
match.sources / tags / entitiesstring[]Subsets matched against the alert's source/tags/entity.
match.messageRegexstring 1–500Validated as a real regular expression (400 if it doesn't compile).
actions.suppressbooleanDrop the alert entirely (no row, no escalation).
actions.delayMinutesint 0–1440Defer the alert (durable — a worker materializes it later).
actions.autoAck / autoClosebooleanApply the lifecycle transition instead of notifying.
actions.addTags / setPrioritystring[] / P1–P5Mutations applied before the alert continues through the pipeline.
At least one action required on create (actions is mandatory and must be non-empty). A bare match with no actions is a 400.
curl -X POST https://api.ops-ping.com/v2/alert-policies \
  -H "Authorization: OpsPingKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Silence synthetic checks at night",
    "match": { "sources": ["synthetics"], "priorities": ["P4","P5"] },
    "actions": { "suppress": true }
  }'
POST/v2/alert-policies/dry-runwrite

Evaluate your tenant's policies against an alert without persisting anything (no alert row, no escalation, no audit entry) — the matcher is the exact engine the create path uses. Body is either {"alertId":"<uuid>"} (an existing stored alert; 404 if not found) or {"alert":{…}} with sample fields, all defaulted (source:"dry-run", priority:"P3", empty tags/message, entity:null, teamId:null) — a bare {"alert":{"source":"grafana"}} is enough. Response: {"data":{"matched":{"id","name","order"}|null,"outcome":…,"mutations"?,"delayedUntil"?}} where outcome is none (no policy matched — matched is null), suppressed, handled (delay/autoAck/autoClose — includes delayedUntil when delayed), or continue (only setPriority/addTags mutations, returned under mutations).

curl -X POST https://api.ops-ping.com/v2/alert-policies/dry-run \
  -H "Authorization: OpsPingKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"alert":{"source":"grafana","priority":"P2","message":"High error rate"}}'
# → {"data":{"matched":{"id":"…","name":"Downgrade grafana noise","order":2},
#            "outcome":"continue","mutations":{"setPriority":"P3","addTags":["auto-downgraded"]}}}
EndpointDescription
GET /v2/alert-policiesAll tenant policies in evaluation order (sorted by order, then id). Items: {id,name,teamId,enabled,order,match,actions,createdAt}.
GET /v2/alert-policies/:idSingle policy, team-checked. 404 raw {"message"}.
PATCH /v2/alert-policies/:idPartial update (all fields optional). Moving teamId requires access to the target team; null clears the binding. → 200 {"data":{"id","result":"updated"}}.
POST /v2/alert-policies/reorderBody {"ids":[…]} naming every tenant policy exactly once (full-set permutation keeps order collision-free). Unknown id → 404 and nothing changes; partial/duplicate set → 400. 200 with the reordered list. One audit entry for the resequencing itself.
DELETE /v2/alert-policies/:idHard delete. → 200 {"data":{"result":"deleted"}}.

Status Pages

One hosted status page per tenant, plus public subscriber management. Admin config lives under /v2/status-page and requires an admin JWT or an API key with the config scope; the public surface under /v2/public/status-page/:slug is unauthenticated (gated per page, rate-limited per IP). Incidents become status-page content via their statusPageEntry field — see Incidents (PATCH /v2/incidents/:id).

EndpointDescription
GET /v2/status-pageThis tenant's config, or 404 NOT_FOUND ("not configured"). Shape: {slug,visibility,brandName,accentColor,logoUrl,showUptimeBars,hasAccessKey,createdAt,updatedAt} — the access key itself is never returned.
PUT /v2/status-pageCreate-or-replace the config. Validated: slug (^[a-z0-9][a-z0-9-]{1,46}[a-z0-9]$, globally unique — 409 CONFLICT "slug taken" if another tenant owns it), visibility: public|link|login, brandName 1–120, accentColor? #rrggbb, logoUrl? URL, showUptimeBars (default false). visibility:"link" requires an access key: an existing key is kept, otherwise one is auto-generated and the plaintext accessKey is returned exactly once in the response; switching away from link clears it. Audit status_page.update.
POST /v2/status-page/rotate-keyRevoke the current link key and mint a new one (plaintext returned once). 404 if not configured; 400 if visibility isn't link. Audit status_page.rotate_key.
GET /v2/status-page/subscribers{"data":{"count":n,"subscribers":[{email,verifiedAt,createdAt}]}}, verified first then pending. Admin/config-scope only.

Public status page

Three-mode gate, checked per request: public — open; link — requires the access key in the X-Status-Key header (a wrong/missing key is 404, never 401/403, so the endpoint never confirms a gated slug exists); login — requires an authenticated session (401 {"message":"authentication required"} when unauthenticated; a valid user from another tenant gets 404). Unknown slugs are 404 {"message":"not found"}.

EndpointDescription
GET /v2/public/status-page/:slugCurated page content (branding, overall status, published incidents, uptime bars when enabled). Behind the gate.
GET /v2/public/status-page/:slug/history?days=Resolved published incidents, newest first, flat array. days default 90, clamped to max 90. Same gate.
POST /v2/public/status-page/:slug/subscribeBody {"email":"…"} (valid email ≤254; 400 otherwise). Double opt-in: a single-use verification email is sent; the row stays pending until verified. Always 202 {"result":"ok"} on accept — verified re-subscribes are a silent no-op. Unknown slug and gated denials collapse to the same 404. Rate limit 5 per IP per hour (429 RATE_LIMIT_EXCEEDED).
GET /v2/public/status-page/:slug/verify?token=Email verification link — single-use (the stored hash is removed on success, so replays 400). Success → 302 redirect; bad/unknown token → 400 {"message":"invalid or expired token"}.
GET /v2/public/status-page/:slug/unsubscribe?token=One-click unsubscribe (the token is the capability, mailed in every fan-out email). Success → 302; bad token → 400. Works in every visibility mode.
curl "https://api.ops-ping.com/v2/public/status-page/acme" 
# link mode:
curl -H "X-Status-Key: THE_ACCESS_KEY" "https://api.ops-ping.com/v2/public/status-page/acme"

Inbound Webhooks & Email

Unauthenticated intake endpoints where the URL token is the credential (SHA-256 hashed, timing-safe compared; 401 for an unknown integration or bad token alike, 403 FORBIDDEN when the integration is disabled, 429 past 300 req/min per token). Created alerts inherit the integration's team/tenant and run the identical POST /v2/alerts pipeline — dedup, routing rules, alert policies, escalation, push. Webhook URLs and inbound email addresses are minted and rotated on the integrations endpoints.

POST/v2/webhooks/inbound/:integrationId/:tokenpublic · token auth

Accepts any JSON object; the payload family is auto-detected per request (integration type can force a mapper).

  • Prometheus Alertmanager v4 / Grafana — bodies with an alerts[] array. Per entry: labels.alertname → message, severity → priority (critical→P1, error→P2, warning→P3, info→P4, debug/none→P5; unknown → P3), fingerprint → alias, status:"resolved" → close by alias. Labels/annotations land in details (label.*/annotation.*); Grafana's externalURL is carried into details. → 202 {"data":{"result":"processed","created","deduped","closed","suppressed","skipped"}} (per-batch tallies).
  • CloudWatch via SNS — envelope {"Type":"Notification","Message":"<JSON string>"}. ALARM → create (alias cloudwatch:<AlarmName>, priority P3, NewStateReason as description); OK → close by alias; INSUFFICIENT_DATA → 204 no body; SubscriptionConfirmation/UnsubscribeConfirmation → 200 {"data":{"result":"acknowledged"}}.
  • Datadog monitor webhook — auto-detected by title + alert_transition/alert_id (or forced by integration type datadog). title → message, alert_type → priority, alias datadog-<alert_id>; alert_transition:"Recovered" → close by alias. 202 generic accepted.
  • Native / generic — any other JSON object is validated against CreateAlertBody (same schema as POST /v2/alerts) and passed through. Response shapes mirror the alerts endpoint, incl. 202 {"result":"suppressed","policyId"} when an alert policy drops it and 202 {"result":"created","delayed":bool,"delayedUntil"} when one delays it.

Close-by-alias is the dedupe mechanism for resolve flows: the mapping follows the alias pointer regardless of alert status and closes the owning alert (idempotent — already-closed is a no-op).

curl -X POST https://api.ops-ping.com/v2/webhooks/inbound/INTEGRATION_ID/WEBHOOK_TOKEN \
  -H "Content-Type: application/json" \
  -d '{"message":"Disk full on db-1","alias":"disk-db-1","priority":"P2"}'
POST/v2/webhooks/inbound-email/:integrationId/:tokenpublic · token auth

Email-to-alert intake; a Cloudflare Email Worker parses the MIME message and POSTs clean JSON here: {"from","to","subject","text","messageId"} (400 BAD_REQUEST "Invalid email payload" otherwise). Mapping: the first [P1][P5] tag in the subject sets priority (default P3); Re:/Fwd:/[tag] prefixes are stripped for the message (fallback: "Email from <from>"); the body text becomes the description; from/to land in details. Dedupe: alias = email:<messageId> — the same message delivered twice never creates two alerts. Create-only (no resolve-by-email). 202 accepted, or 202 {"result":"suppressed"} when an alert policy suppresses it. Same 401/403/429 token model and 300/min per-token limit as the webhook endpoint.

Integrations & Tokens

Integrations own API keys and inbound webhook URLs. This router additionally requires the config scope for API-key callers on every method — and keys minted here never get config — so in practice integration management is JWT-only. Regular JWT users default to read; writes need an explicit grant.

EndpointDescription
GET /v2/integrationsList: {id,name,type,enabled,allowWrite,allowRead,allowDelete,allowConfig,suppressNotifications,createdAt}. Query: limit, cursor. Secrets are not included.
POST /v2/integrationsBody (unvalidated): name, type? (default "API"), enabled?, apiKey? (raw key to store), allowWrite/Read/Delete? (default true), allowConfig? (default false), suppressNotifications?, config?. → 200 {"data":{"id","apiKey","webhookUrl","result":"created"}}. Always mints an inbound webhook token; apiKey echoes your supplied key (or null). No name-uniqueness check.
GET /v2/integrations/:idDetail: adds config (fields named like key/pass/secret/token/header masked as "********"), webhookUrl, apiKeys:[{id,keyHash,keyPrefix,lastUsed,createdAt}]. Note: keyPrefix is a prefix of the bcrypt hash — nearly identical across keys, useless for telling keys apart.
PATCH /v2/integrations/:idPartial update. config is merged after cleaning — sending "********" masks or empty strings leaves stored secrets untouched.
POST /v2/integrations/authenticateCredential-validation ping: API key → {"data":{"result":"authenticated","integrationId"}}; JWT → {"result":"authenticated","authType":"jwt","userId"}. (API keys need config scope, which they don't have — JWT only in practice.)
POST /v2/integrations/:id/testConfig-presence check only — nothing is actually sent despite the "Test signal queued for delivery" message. Validates required config by type (slack/teams → webhookUrl, email → fromAddress, webhook → url); other types → 400.
POST /v2/integrations/:id/regenerate-keyBody {"confirm":true} (required). Deletes all existing keys, returns one new raw key (pager-…) shown only here: {"data":{"apiKey":"pager-…","result":"regenerated"}}. Old keys linger ≤15s (auth cache).
POST /v2/integrations/:id/rotate-webhook-tokenBody {"confirm":true}. New inbound webhook URL; the old URL is invalid immediately.
POST /v2/integrations/:id/enable
POST /v2/integrations/:id/disable
Toggle enabled (convenience wrappers over PATCH).
DELETE /v2/integrations/:idCascade-deletes all of the integration's API keys, then the integration.
curl -X POST https://api.ops-ping.com/v2/integrations/INTEGRATION_ID/regenerate-key \
  -H "Authorization: Bearer YOUR_ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{"confirm": true}'

Tokens — /v2/tokens

Cross-integration view of all API keys. GET /v2/tokens{"data":[{id,integrationId,integrationName,keyPrefix,scopes,createdAt,lastUsedAt}]} (newest first, no pagination). DELETE /v2/tokens/:id revokes a key (:id is the key record ID — the SHA-256 hex of the raw key; API-key callers need the delete scope). Revocation takes effect within ~15 seconds.

Saved Searches

Named alert-list queries: {id,name,query,teamId,createdAt}. Standard CRUD: GET / (query: limit, cursor, q substring over name+query), POST / ({"name","query"}), PATCH /:id, DELETE /:id.

Scoping quirks: team-bound callers see only their own team's searches — global (team-less) searches are hidden from them, and there is no ?allTeams=. Callers with no team see every team's searches. PATCH/DELETE perform no ownership or team check — any user with default write can edit or delete another team's saved search.

Audit Log

GET/v2/audit-logany authenticated caller

Query the audit trail, newest first. Entries: {id,action,entityType,entityId,entityName,userId,userName,details,createdAt}. Composable filters: entityType, user, action, from/to (ISO or YYYY-MM-DD; date-only to is inclusive), q (substring across user/action/entity fields), limit (50/200). totalCount is the true match count. cursor is accepted but nextCursor is never emitted — rely on limit/totalCount. No team scoping: any authenticated caller (incl. read-scoped API keys) can read the whole trail.

curl "https://api.ops-ping.com/v2/audit-log?entityType=notification-rule&from=2026-07-01&limit=100" \
  -H "Authorization: OpsPingKey YOUR_API_KEY"
POST/v2/audit-logadmin JWT only

Write a custom entry: {"action","entityType","entityId","entityName"?,"userId"?,"userName"?,"details"?} → 200 {"data":{"id","result":"logged"}}. API keys can never use this (role check is admin-only).

Reports

Alert analytics computed on demand over a trailing window. Team-scoped: callers with a teamId see only their team's alerts (callers without one — including teamless admins — see all); team-less alerts are invisible to team-bound callers. No ?allTeams= override. All stats endpoints accept ?days= (default 30, clamped 1–365) and scan the full window — cost grows with days.

EndpointResponse (data)
GET /v2/reports/summary{period:{days,since}, totals:{total,open,acknowledged,closed}, mtta:{minutes,seconds}, mttr:{minutes,seconds}, byPriority:{…}, bySource:[{source,count}]} (top 10 sources).
GET /v2/reports/trends[{date,total,open,acked,closed}] per UTC day, ascending. Extra ?offset= shifts the window back for period-over-period comparisons. (Key is acked here vs acknowledged in summary.)
GET /v2/reports/top-sources[{source,count}], top 10.
GET /v2/reports/mtta · /mttr{total:{seconds}, breakdown:[{key,seconds}]|null}. Optional ?breakdown=responder|team|service (invalid values silently ignored → null).
GET /v2/reports/responder · /team · /service[{key,total,mtta:{seconds},mttr:{seconds}}] grouped per dimension, sorted by volume.
GET /v2/reports/export?days=&format=csv|pdf. CSV download (pager-report-<days>d.csv; header column assignedTo actually contains assignedToUserId). "PDF" is a one-page stub (title + total count only). Any format other than exactly pdf yields CSV.
curl "https://api.ops-ping.com/v2/reports/summary?days=7" \
  -H "Authorization: OpsPingKey YOUR_API_KEY"

On-call hours — /v2/reports/on-call-hours

GET/v2/reports/on-call-hoursread

Per-user on-call hours over a date range, computed from rotations and overrides (same shift math as GET /v2/schedules/:id/shifts). Query: from/to (ISO 8601; to defaults to now, from to now−30d; 400 if unparseable or to ≤ from; 400 "Range too large" beyond 366 days), optional scheduleId/teamId filters, format=json|csv (default json). Schedules are team-scoped like other lists (own team + global). JSON: {"data":{"from","to","rows":[{"userId","userName","scheduleId","scheduleName","shiftCount","totalHours","businessHours","afterHours"}]}}, sorted by totalHours desc, hours rounded to 2 decimals. Business hours = Mon–Fri 09:00–17:00 in each schedule's own timezone, DST-safe (day boundaries computed from the offset in effect at each boundary); everything else is after-hours. CSV (format=csv) downloads oncall-hours-<from>_<to>.csv with header user,schedule,shifts,total_hours,business_hours,after_hours — cells starting with =, +, -, or @ are quote-prefixed so the file is formula-injection safe.

curl "https://api.ops-ping.com/v2/reports/on-call-hours?from=2026-08-01T00:00:00Z&to=2026-08-31T00:00:00Z&format=csv" \
  -H "Authorization: OpsPingKey YOUR_API_KEY"

Report schedules — /v2/reports/schedules

EndpointDescription
GET /v2/reports/schedulesOwn schedules (admins see all): {id,userId,cadence,email,days,enabled,nextRunAt,lastRunAt?,createdAt,updatedAt}.
POST /v2/reports/schedulesValidated: cadence: daily|weekly|monthly, email, days? (1–365, default 30) → 201 with the full schedule.
DELETE /v2/reports/schedules/:idOwner or admin. 403 Not your report schedule.
POST /v2/reports/schedules/:id/runTrigger now. Rolls nextRunAt forward and sets lastRunAt regardless of outcome.
Config-only today: no background worker fires report schedules (nextRunAt is computed but nothing consumes it), delivery is a stub that can never succeed even with providers configured, and there is no endpoint to toggle enabled.

Additional APIs

Push tokens — /v2/push-tokens

EndpointDescription
POST /v2/push-tokensRegister an Expo push token: {"token":"ExponentPushToken[…]","platform"?} (400 unless it starts with ExponentPushToken). Upsert keyed on the token; owner = caller (userId in the body is accepted but ignored). → {"data":{"result":"registered","userId"}}.
DELETE /v2/push-tokens/:tokenUnregister (URL-encode the token). Only the owner or an admin; tokens registered with a null owner are admin-only. 404 Push token not found.

Inbound webhooks — /v2/webhooks/inbound/:integrationId/:token

Public alert intake authenticated by the per-integration URL token, with auto-detection for Alertmanager, Grafana, CloudWatch/SNS, and Datadog payloads — see Inbound Webhooks & Email.

Alert policies — /v2/alert-policies

Server-side alert preprocessing on create (suppress, delay, auto-ack/close, add tags, set priority) — full CRUD, dry-run, and reorder documented under Alert Policies.

Routing rules — /v2/routing-rules

Assign a team to incoming alerts that match: {id,name,enabled,order,match:{priorities?,sources?,tags?,entities?,messageRegex?},targetTeamId,createdAt,updatedAt} (empty match = catch-all). CRUD: GET / (team-scoped list), POST / (201), PUT|DELETE /:id. Manage rights: global admins, or admin/lead members of the target team; retargeting requires rights on both teams. Note: PUT is a partial merge, not a full replace.

Forwarding rules — /v2/forwarding-rules

User-scoped notification forwarding (GET /, POST /, GET /:id, PUT /:id, DELETE /:id). JWT only — API keys get 403 Forwarding rules are user-scoped. Fields: {id,fromUserId,toUserId,startDate?,endDate?,alias?,enabled,createdAt}. No input validation (missing toUserId is stored as-is); PUT responses leak internal storage keys (PK/SK).

Postmortems — /v2/postmortems

Incident write-ups — CRUD, audit-trail generation, and Markdown/PDF export documented under Postmortems.

Public status — /v2/public/status

Unauthenticated, unrate-limited. Incidents flagged for the status page: {"data":[{id,message,severity,status,createdAt,resolvedAt,updatedAt}],"generatedAt":"…","overallStatus":"operational"|"degraded"}. An incident appears when its statusPageEntry.enabled is true; severity comes from the status-page entry (default info), not the incident priority.

Data export — /v2/export

GET /v2/export — GDPR-style self-export for the calling user: {"data":{"exportedAt","user":{…}|null,"alerts":[… assigned to you …],"notificationRules":[…],"channels":[…]}} (pseudo-envelope: no took/requestId; no rate limiting). Results are a single database page — very large datasets are silently truncated.

Account — /v2/account

GET /v2/account — deployment info: {"data":{"name":"OpsPing","userCount":n,"teamCount":n,"plan":"self-hosted"}}. Counts are global (not team-scoped) and visible to any authenticated caller including API keys.

Health & Debug

GET/healthpublic · no envelope

Liveness probe. Shallow: 200 {"status":"ok"} / 503 {"status":"error","message":"Database unreachable"} — this shape is a stability contract. Deep mode triggers on the presence of ?deep (any value, even ?deep=0): adds {"checks":{"dynamodb":"up"|"down","providers":{"sms":bool,"voice":bool,"email":bool}}} (provider booleans mean "configured", not credential-validated); 503 when the DB is down.

curl https://api.ops-ping.com/health
curl "https://api.ops-ping.com/health?deep=1"
Debug routes (non-production only): when NODE_ENV ≠ production, the server also mounts POST /debug/auth-test, POST /debug/test-push (authed), POST /debug/register-push, GET /debug/process-escalations, POST /debug/force-escalation/:alertId, and POST /debug/create-test-alert — all unauthenticated (except test-push), and several allow arbitrary writes. They 404 in production. Never expose a non-production build to an untrusted network.

OpsGenie Compatibility

The alert pipeline is drop-in compatible with the OpsGenie v2 REST API: point your existing integration at the OpsPing base URL and keep your GenieKey header — it is accepted as-is. See the migration guide for step-by-step instructions and the import script.

EndpointCompatNotes
Authorization: GenieKey <key>✓ FullChecked as the second API-key form; no config needed.
POST /v2/alerts✓ FullIdentical payload format; 202 + requestId. The created alert's id is returned top-level as alertId (unlike OpsGenie's async contract, which returns only a requestId, though OpsPing processes synchronously).
GET /v2/alerts◐ Partiallimit/query/status/sort exist, but query is a substring match — the OpsGenie query language (status: open AND priority: P1) is not implemented. No offset/cursor parity.
GET /v2/alerts/:identifier✓ FullUUID or tinyId. (OpsPing tinyIds are random and not guaranteed unique — prefer UUIDs.)
POST /v2/alerts/:id/acknowledge · /unacknowledge · /close · /snooze · /notes✓ Full202 Accepted semantics match.
GET /v2/alerts/:id/notes · /activities✓ FullActivities are nested under data.activities.
DELETE /v2/alerts/:id✓ FullHard delete.
POST /v2/integrations/authenticate✓ FullCredential-validation ping (JWT-auth only in practice — integration keys lack the config scope this router requires).
GET /v2/schedules · GET /v2/schedules/:id/on-calls✓ FullSame response shape incl. onCallRecipients.
POST /v2/heartbeats/:name/ping✓ FullAuto-creates unknown heartbeats, same as OpsGenie. Requires an API key with write scope (as does OpsGenie).
Heartbeats CRUD✓ FullName-keyed, 409 on duplicate.
Users / Teams / Escalations / Incidents / etc.✗ NativeOpsPing-native surfaces with their own shapes (documented above), not OpsGenie clones.
Opsgenie Edge / JSM / analytics✗ NoOut of scope — see the migration guide.