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
- Keys are looked up by SHA-256 of the raw key and verified with bcrypt (v2 keys). A 15-second in-memory cache avoids re-hashing on every request — revoked or rotated keys stay valid for up to ~15 seconds.
- A key carries scopes:
read,write,delete,config. Method mapping: GET/HEAD/OPTIONS →read, POST/PUT/PATCH →write, DELETE →delete. Keys minted via the API getread/write/deletebut notconfig(the integrations router requiresconfig, so integration keys cannot manage integrations). - A key can be bound to a team; team-bound keys only see and mutate their team's resources plus global (team-less) ones.
- Internally a key authenticates as a synthetic user
{ sub: integrationId, role: "api_key", teamId }. Endpoints documented as "JWT only" reject this context. - Browser note: CORS
allowHeadersisContent-Type, Authorizationonly. Cross-origin browser callers must use anAuthorizationscheme —X-OpsPing-Keyfails preflight. Server-to-server callers are unaffected.
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
| Layer | Applies to | Behavior |
|---|---|---|
| Method scopes | API keys | Key must hold the scope implied by the HTTP method (read/write/delete). Missing → 403. |
| User permissions | JWT users | Per-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 scoping | Both | Single-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
| Limiter | Limit | Keying |
|---|---|---|
| General (per-router) | 120 req/min authenticated, 60 req/min anonymous | SHA-256 of the Authorization header, else client IP. Fixed 60s window, DynamoDB counter, fail-open on DB error. |
| Registration | 3 per IP per hour | POST /v2/auth/register only; only enforced when a real proxy IP header is present. |
| Inbound webhooks | 300 req/min | Per webhook URL token. |
| Channel resend | 1 per 30s | Per channel verification code resend. |
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).
{"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.
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": []
}
Create an alert. Validated by the CreateAlertBody zod schema.
| Field | Type | Notes |
|---|---|---|
| message | string 1–130 | Required. |
| alias | string ≤512 | Dedup 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. |
| priority | P1–P5 | Default P3. |
| description | string ≤15000 | |
| source / entity / user | string ≤100/512/100 | |
| tags | string[] | Max 20, each ≤50 chars. |
| details | record<string,string> | Custom key/value payload. |
| responders | object[] ≤50 | { type: "user"|"team"|"schedule"|"escalation", id?, name?, username? } |
| skipEscalation | boolean | Skip the escalation engine; falls back to a team-scoped push broadcast. |
| note | string ≤25000 | Accepted 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" }]
}'
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"
Alert count, team-scoped like the list (allTeams=true honored; no status filter). Response: {"data":{"count":42},…}.
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.
| Endpoint | Effect | Notes |
|---|---|---|
| POST /:id/acknowledge | status=acknowledged, stops escalation | |
| POST /:id/unacknowledge | Back to open, resumes escalation | |
| POST /:id/close | status=closed, completes escalation | Leaves acknowledged untouched — a closed alert can remain un-acked. |
| POST /:id/reopen | closed → open, acknowledged=false, resumes escalation | 409 unless the alert is closed. |
| POST /:id/snooze | Body {"endTime":"<ISO>"}; sets snoozed/snoozedUntil | endTime not validated; a missing body throws a 500. Writes no activity entry. |
| POST /:id/unsnooze | Clears snooze | 409 if not snoozed. |
| POST /:id/assign | Sets owner | Body {"user":"…","note":"…"} — empty body is accepted (owner defaults to "api"). Unvalidated. |
| POST /:id/escalate | Immediately fires the next escalation step | 200 {"data":{"escalated":true|false,"reason":"…","recipient?":…}} — fires the real engine. |
| POST /:id/read | Appends 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-escalation | Advances the escalation engine for this alert | 200 with the engine result. Authenticated despite a stale "no auth" code comment. |
Notes, activities, details, responders, tags
| Endpoint | Description |
|---|---|
| POST /:id/notes | Add a note. Body {"note":"…","user?":"…"} (unvalidated; user defaults "api"). 202. |
| GET /:id/notes | List notes, newest first: {"data":[{"id","note","user","createdAt"}], "totalCount",…}. |
| GET /:id/activities | Activity timeline, newest first, unpaginated: {"data":{"activities":[{"id","type","user","createdAt","metadata"}]},…} (nested under data.activities, not a bare list). |
| POST /:id/details | Shallow-merge {"details":{…}} into custom details. 200 with the merged object. |
| POST /:id/responders | Add responders: {"responders":[{"type","name?","id?"}]}. 200 {"data":{"result":"added","count":n}}. |
| GET /:id/recipients | Bare array of user IDs actually notified (harvested from escalation activity metadata): {"data":["userId",…]}. |
| POST /:id/tags | Union-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.
| Endpoint | Description |
|---|---|
| POST /:id/attachments | Mint 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/complete | Finalize: 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/attachments | Uploaded attachments only: {"data":[{"id","fileName","contentType","size","uploadedBy","createdAt","downloadUrl"}]} — each row carries a fresh presigned download URL valid 1 hour. |
| DELETE /:id/attachments/:attachmentId | Deletes 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"
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.
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.
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":"…"}'
| Endpoint | Description |
|---|---|
| POST /v2/auth/register | Body {"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/refresh | Body {"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/me | Two 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/logout | Revokes the current token's jti, clears the cookie → {"data":{"result":"logged_out"}}. |
| POST /v2/auth/revoke-all | JWT only. Increments tokenVersion (kills all sessions) and blacklists the caller → {"data":{"result":"revoked_all","tokenVersion"}}. |
| POST /v2/auth/forgot | Body {"email"}. Always 200 with a generic message (anti-enumeration). Emails a 1-hour reset link when the email provider is configured. |
| POST /v2/auth/reset | Body {"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)
| Endpoint | Description |
|---|---|
| POST /v2/auth/mfa/enroll | JWT. → {"data":{"secret","otpauthUri"}} (issuer OpsPing). Nothing is persisted until verify. |
| POST /v2/auth/mfa/verify | Body {"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/disable | Disables MFA — no password or TOTP confirmation required. |
| POST /v2/auth/login/mfa | Body {"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/backup | Same shape with a backup code; codes are single-use. |
SSO (OIDC)
| Endpoint | Description |
|---|---|
| GET /v2/auth/sso/:teamSlug | Public. 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/callback | Public 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
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"
| Endpoint | Description |
|---|---|
| GET /v2/users/:id | Single user (adds mfaEnabled). 404 {"message":"User not found"}. |
| POST /v2/users | Validated: 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/:id | Admin 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/:id | Admin or self. Deletes the user record only — push tokens, memberships, channels, and notification rules are orphaned. |
| POST /v2/users/:id/mfa/reset | Admin JWT only. Clears MFA + kills sessions, writes an audit entry. 200 even if MFA wasn't enabled (mfa_not_enabled). |
| GET /v2/users/:id/escalations | Escalation policies whose rules target this user. |
| GET /v2/users/:id/schedules | Bug: returns all schedules in the system, not the user's — :id only gates the 404. |
| GET /v2/users/:id/teams | Teams the user belongs to (correctly user-scoped). |
Teams — /v2/teams
| Endpoint | Description |
|---|---|
| GET /v2/teams | Teams 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/teams | Body {"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/:id | Team + members. Team-checked. 404 raw {"message"}. |
| PATCH /v2/teams/:id | {"name"?,"description"?} only. Team-checked. Empty body → 200 "updated" no-op. |
| DELETE /v2/teams/:id | Deletes 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/:userId | Removes 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
| Endpoint | Description |
|---|---|
| POST /v2/invites | Validated: {"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/invites | Pending invites, newest first. Expired-but-unused invites still appear. |
| GET /v2/invites/:token | Public. Invite details; 404 NOT_FOUND, 410 INVITE_USED (used or expired). |
| POST /v2/invites/:token/accept | Public. 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/:token | Revoke (hard delete; works on used/expired too). |
Schedules & PTO
Schedules — /v2/schedules
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).
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" }]
}]
}'
| Endpoint | Description |
|---|---|
| GET /v2/schedules/:id | Schedule + rotations + overrides:[{id,userId,userName,startTime,endTime}]. Optional ?start=&end= (ISO) narrows overrides to the window. Team-checked. |
| PATCH /v2/schedules/:id | Update 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/:id | Cascades to rotations and overrides. |
Rotations & overrides
| Endpoint | Description |
|---|---|
| GET /v2/schedules/:id/rotations | List rotations (totalCount envelope). |
| POST /v2/schedules/:id/rotations | Add 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/:rid | Single rotation. 404 Rotation not found. |
| PATCH /v2/schedules/:id/rotations/:rid | Partial update; only present fields change (timeRestriction can be nulled). |
| DELETE /v2/schedules/:id/rotations/:rid | Delete one rotation. |
| POST /v2/schedules/:id/overrides | Body {"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/:oid | Removes an override → 200 override removed. Never 404s, even when the override doesn't exist. |
On-call queries
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"
| Endpoint | Description |
|---|---|
| GET /v2/schedules/:id/timeline | Per-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/shifts | Computed 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/ical | text/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
| Endpoint | Description |
|---|---|
| GET /v2/pto-requests | List, 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-requests | Body {"startDate","endDate","reason"?} (400 if missing or end ≤ start) → 200 {"data":{"id","status":"pending","result":"created"}}. Owner = caller. |
| PATCH /v2/pto-requests/:id/approve | Admins, or lead/admin member of any team (not necessarily the requester's). 400 unless pending. |
| PATCH /v2/pto-requests/:id/deny | Same authz as approve. |
| DELETE /v2/pto-requests/:id | Owner 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.
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" }
]
}'
| Endpoint | Description |
|---|---|
| GET /v2/escalations | List (?allTeams=true opts out of team filtering). Items: {id,name,description,teamId,priorities,isDefault,repeatInterval,repeatCount,closeAlertAfterAll,createdAt}. No pagination. |
| GET /v2/escalations/:id | Detail incl. ordered rules:[{id,condition,notifyType,delayMinutes,recipientType,recipientId,recipientName,order}]. Also returns resetRecipientStates (always false — not settable via the API). |
| PATCH /v2/escalations/:id | Partial 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/:id | Deletes 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.
| Endpoint | Description |
|---|---|
| GET /v2/incidents | List: {id,name,status,message,description,teamId,createdAt,priority,tags,impactedServices,statusPageEntry}. Query: limit, cursor, allTeams=true. |
| POST /v2/incidents | Body (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/:id | Detail (adds updatedAt, details, responders, notifyStakeholders). Team-checked. |
| PATCH /v2/incidents/:id | Updates status/priority/tags/details/responders/impactedServices/statusPageEntry/notifyStakeholders only — name/message/description are silently ignored. |
| DELETE /v2/incidents/:id | Team-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.
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"}'
| Endpoint | Description |
|---|---|
| GET /v2/postmortems | List, team-scoped via the parent incident (own team + global incidents). Optional ?teamId= narrows to that team's incidents. |
| POST /v2/postmortems | Create manually. Validated (incidentId + summary required; 400 VALIDATION); incident must exist (404) and pass team/tenant checks. → 201 {"data":{"id",…body,"createdAt"}}. |
| GET /v2/postmortems/:id | Single postmortem. 404 raw {"message"}. |
| PATCH /v2/postmortems/:id | Update summary/rootCause/timeline/actionItems (validated; incidentId immutable). → 200 {"data":{"id","result":"updated"}}. |
| DELETE /v2/postmortems/:id | Hard delete. → 200 {"data":{"result":"deleted"}}. |
| GET /v2/postmortems/:id/export | Download as Markdown (?format=md, default — text/markdown attachment) or PDF (?format=pdf — application/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.
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.
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"
| Endpoint | Description |
|---|---|
| POST /v2/heartbeats | Create (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/heartbeats | List: {name,description,interval,intervalUnit,enabled,expired,lastPing,serviceId,alertMessage,alertTags,alertPriority}. Query: limit (50/200), cursor, allTeams=true. |
| GET /v2/heartbeats/:name | Single heartbeat. Team-checked. 404 Heartbeat not found. |
| PATCH /v2/heartbeats/:name | Partial update of any create field except name (no rename — name is the key). Empty body → 200 no-op. |
| DELETE /v2/heartbeats/:name | Team-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.
| Endpoint | Description |
|---|---|
| GET /v2/maintenance-windows | List: {id,name,description,startTime,endTime,serviceId,enabled,teamId,createdAt}. Query: limit, cursor, allTeams=true. |
| POST /v2/maintenance-windows | Body: name, startTime, endTime (required, ISO; stored unvalidated — end-before-start accepted), description?, serviceId?, suppressAlerts?. → 200 {"data":{"id","result":"created"}}. |
| GET /v2/maintenance-windows/:id | Single window. Team-checked. |
| PATCH /v2/maintenance-windows/:id | Partial update; accepts enabled (see quirk below). |
| DELETE /v2/maintenance-windows/:id | Team-checked; API keys need delete scope. |
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=.
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"}'
| Endpoint | Description |
|---|---|
| GET /v2/channels | Own channels, oldest first: {"ok":true,"channels":[…],"providerDisabled":bool}. |
| POST /v2/channels/:id/verify | Body {"code":"123456"} (6 digits). Idempotent; success consumes the code. 401 INVALID_CODE (wrong/expired). |
| POST /v2/channels/:id/resend | New 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/test | Real 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/:id | Deletes 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).
Create a rule (validated). → 200 {"data":{"id","result":"created"}}. order is server-assigned (append); change it only via /reorder.
| Field | Type | Notes |
|---|---|---|
| name | string 1–120 | Required. |
| enabled | boolean | Default true. |
| criteria.priorities | P1–P5[] | Default []. |
| criteria.sources / tags | string[] | Default []. |
| criteria.actionTypes | enum[] | create-alert|acknowledged|closed|assigned. |
| criteria.criteriaType | enum | match-all (default) | match-any — applies to priority/tags/source/actionType only. |
| criteria.teamIds | string[] | 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.scheduleId | string | On-call gate: rule only fires when the owner is on call (override wins, else rotation math). |
| action.channelIds | string[] | Verified channels to notify. |
| action.delayMins | int 0–1440 | Durable delayed delivery. |
| repeat.enabled / intervalSec / maxRepeats | bool / 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 }
}'
| Endpoint | Description |
|---|---|
| GET /v2/notification-rules | Own rules sorted by order. ?userId= (admin) to inspect another user. |
| GET /v2/notification-rules/:id | Single rule. 404 raw {"message"}. |
| PATCH /v2/notification-rules/:id | Partial — but replace, not merge: a provided criteria/action/repeat object is re-defaulted, wiping omitted subfields. Send complete sub-objects. |
| DELETE /v2/notification-rules/:id | Delete. Remaining rules keep their order values (no renumbering). |
| POST /v2/notification-rules/reorder | Body {"ids":[…]} — must name every rule exactly once (all-or-nothing validation). 200 with the full reordered list. |
| POST /v2/notification-rules/:id/match | Dry-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. |
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.
Create a policy (validated, 400 VALIDATION). → 200 {"data":{"id","result":"created"}}.
| Field | Type | Notes |
|---|---|---|
| name | string 1–200 | Required. |
| teamId | string | null | Bind to a team; null = tenant-global. Defaults to a team-bound API key's team. |
| enabled | boolean | Default true. Disabled policies are skipped by the matcher. |
| order | int ≥0 | Default 0; evaluation order (change it via /reorder). |
| match.priorities | P1–P5[] | Alert priority must be in the list. |
| match.sources / tags / entities | string[] | Subsets matched against the alert's source/tags/entity. |
| match.messageRegex | string 1–500 | Validated as a real regular expression (400 if it doesn't compile). |
| actions.suppress | boolean | Drop the alert entirely (no row, no escalation). |
| actions.delayMinutes | int 0–1440 | Defer the alert (durable — a worker materializes it later). |
| actions.autoAck / autoClose | boolean | Apply the lifecycle transition instead of notifying. |
| actions.addTags / setPriority | string[] / P1–P5 | Mutations applied before the alert continues through the pipeline. |
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 }
}'
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"]}}}
| Endpoint | Description |
|---|---|
| GET /v2/alert-policies | All tenant policies in evaluation order (sorted by order, then id). Items: {id,name,teamId,enabled,order,match,actions,createdAt}. |
| GET /v2/alert-policies/:id | Single policy, team-checked. 404 raw {"message"}. |
| PATCH /v2/alert-policies/:id | Partial 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/reorder | Body {"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/:id | Hard 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).
| Endpoint | Description |
|---|---|
| GET /v2/status-page | This 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-page | Create-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-key | Revoke 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"}.
| Endpoint | Description |
|---|---|
| GET /v2/public/status-page/:slug | Curated 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/subscribe | Body {"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.
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 indetails(label.*/annotation.*); Grafana'sexternalURLis 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 (aliascloudwatch:<AlarmName>, priority P3,NewStateReasonas 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 typedatadog).title→ message,alert_type→ priority, aliasdatadog-<alert_id>;alert_transition:"Recovered"→ close by alias. 202 generic accepted. - Native / generic — any other JSON object is validated against
CreateAlertBody(same schema asPOST /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"}'
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.
| Endpoint | Description |
|---|---|
| GET /v2/integrations | List: {id,name,type,enabled,allowWrite,allowRead,allowDelete,allowConfig,suppressNotifications,createdAt}. Query: limit, cursor. Secrets are not included. |
| POST /v2/integrations | Body (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/:id | Detail: 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/:id | Partial update. config is merged after cleaning — sending "********" masks or empty strings leaves stored secrets untouched. |
| POST /v2/integrations/authenticate | Credential-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/test | Config-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-key | Body {"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-token | Body {"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/:id | Cascade-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.
?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
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"
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.
| Endpoint | Response (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
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
| Endpoint | Description |
|---|---|
| GET /v2/reports/schedules | Own schedules (admins see all): {id,userId,cadence,email,days,enabled,nextRunAt,lastRunAt?,createdAt,updatedAt}. |
| POST /v2/reports/schedules | Validated: cadence: daily|weekly|monthly, email, days? (1–365, default 30) → 201 with the full schedule. |
| DELETE /v2/reports/schedules/:id | Owner or admin. 403 Not your report schedule. |
| POST /v2/reports/schedules/:id/run | Trigger now. Rolls nextRunAt forward and sets lastRunAt regardless of outcome. |
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
| Endpoint | Description |
|---|---|
| POST /v2/push-tokens | Register 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/:token | Unregister (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
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"
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.
| Endpoint | Compat | Notes |
|---|---|---|
| Authorization: GenieKey <key> | ✓ Full | Checked as the second API-key form; no config needed. |
| POST /v2/alerts | ✓ Full | Identical 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 | ◐ Partial | limit/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 | ✓ Full | UUID or tinyId. (OpsPing tinyIds are random and not guaranteed unique — prefer UUIDs.) |
| POST /v2/alerts/:id/acknowledge · /unacknowledge · /close · /snooze · /notes | ✓ Full | 202 Accepted semantics match. |
| GET /v2/alerts/:id/notes · /activities | ✓ Full | Activities are nested under data.activities. |
| DELETE /v2/alerts/:id | ✓ Full | Hard delete. |
| POST /v2/integrations/authenticate | ✓ Full | Credential-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 | ✓ Full | Same response shape incl. onCallRecipients. |
| POST /v2/heartbeats/:name/ping | ✓ Full | Auto-creates unknown heartbeats, same as OpsGenie. Requires an API key with write scope (as does OpsGenie). |
| Heartbeats CRUD | ✓ Full | Name-keyed, 409 on duplicate. |
| Users / Teams / Escalations / Incidents / etc. | ✗ Native | OpsPing-native surfaces with their own shapes (documented above), not OpsGenie clones. |
| Opsgenie Edge / JSM / analytics | ✗ No | Out of scope — see the migration guide. |