API reference
Endpoints for your server: send a notification, read its status back, and poll decision answers. They use a project API key.
Authentication
Send the key as Authorization: Bearer $PINGLOOM_API_KEY. Sending needs the notifications:send scope; reading status needs events:read. Keys start with pl_live_ in production and pl_test_ everywhere else.
Careful: Never put an API key in a mobile app or browser code. Call PingLoom from your server.
Headers
Content-Type: application/jsonis required. Anything else returns 415.Idempotency-Keyis optional: 1 to 255 printable ASCII characters. The same key with the same body returns the original event for 24 hours, with the response headerIdempotent-Replayed: true. The same key with a different body returns 409.- The 202 response carries
Location, the URL to read the status from.
Fields
POST /v1/notifications takes a JSON object. Only title is required.
| Field | Type | Required | Rule |
|---|---|---|---|
| channel | string | No | Defaults to general. Must match ^[a-z0-9][a-z0-9-]{0,39}$. A well-formed slug that does not exist in the key's project returns 422 INVALID_CHANNEL. |
| title | string | Yes | 1 to 120 Unicode code points after trimming. No control characters. |
| body | string | No | Up to 2000 code points. New lines are allowed. |
| priority | string | No | low, normal or high. Defaults to normal. |
| event_type | string | No | Matches ^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$. |
| external_id | string | No | 1 to 200 printable characters. Your own reference for the event. |
| data | object | No | A JSON object. Its compact UTF-8 form is at most 8192 bytes. Data is not sent in the push. |
| action_url | string | No | An absolute https URL with a host and no credentials, up to 2048 characters. PingLoom never fetches it. |
| decision | object | No | Attach a question for your team. prompt (up to 200 characters), options (2 to 5 items of {id, label}, id a slug up to 32 characters, label up to 40), allow_comment (boolean) and expires_in (2h, 30m, 1d or PT2H; 5 minutes to 7 days, default 24 hours). Owners and admins answer; you read the answer back by polling. policy names a decision policy (slug or id) an owner or admin created in the dashboard (Business plan); it sets who may decide and how many approvals are needed. You can only name a policy, never define one, so roles is rejected. requested_by (a member's user id or email) stops a policy's requester from approving their own request. confirm_twice, require_note and require_device_unlock (booleans) add safeguards: a second confirmation screen, a required note of up to 500 characters returned with the answer, and a phone unlock check in the mobile app (checked on the phone only, not verifiable by the server). A card can only switch these on, never turn off a policy's setting. A decision raises priority low to normal. Plan limit: decision cards a month. |
| occurred_at | string | No | RFC 3339 time with an offset, within 365 days in the past and 1 day in the future. |
The whole request body is at most 32768 bytes. Any other key returns 422 UNKNOWN_FIELD.
Response
PingLoom answers 202 only after the event is stored. Delivery to devices happens afterwards.
HTTP/1.1 202 Accepted
Location: /v1/notifications/01J9ZQ8M3K7W4X2B6N5T0R1VCD
{
"event_id": "01J9ZQ8M3K7W4X2B6N5T0R1VCD",
"status": "accepted",
"created_at": "2026-10-22T09:30:00Z",
"status_url": "/v1/notifications/01J9ZQ8M3K7W4X2B6N5T0R1VCD",
"idempotent_replay": false,
"deduplicated": false
}idempotent_replay is true when the response repeats an earlier request. deduplicated is true when an existing event with the same external_id was returned because the project's deduplication window is on.
Errors
Every error uses one envelope. request_id is what support needs. Validation errors list each problem in details[].
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request has invalid fields.",
"request_id": "req_01J9ZQ8M3K",
"details": [
{ "field": "title", "message": "Title is required.", "code": "REQUIRED" }
]
}
}| HTTP | Code | When |
|---|---|---|
| 400 | INVALID_JSON | The body is not valid UTF-8 JSON, or is not an object. |
| 400 | INVALID_IDEMPOTENCY_KEY | The Idempotency-Key header is not 1 to 255 printable ASCII characters. |
| 401 | INVALID_API_KEY | The key is missing, unknown, revoked or expired. |
| 403 | INSUFFICIENT_SCOPE | The key does not have the notifications:send scope. |
| 409 | IDEMPOTENCY_CONFLICT | The same Idempotency-Key was used with a different request body. |
| 409 | PROJECT_ARCHIVED | The key's project is archived. |
| 413 | PAYLOAD_TOO_LARGE | The request body is over the size limit. |
| 415 | UNSUPPORTED_MEDIA_TYPE | The Content-Type is not application/json. |
| 422 | VALIDATION_ERROR | A field is invalid. Every problem is listed in details[], each with a field, a message and a code. |
| 422 | INVALID_CHANNEL | The channel slug is well-formed but does not exist in the key's project. |
| 429 | RATE_LIMITED | Too many requests for this key or IP. Wait for the Retry-After header. |
| 429 | QUOTA_EXCEEDED | The workspace used its monthly events. Retry-After and X-PingLoom-Quota-Reset say when the period resets. |
| 5xx | INTERNAL_ERROR | Something failed on our side. Nothing was counted. Retry with the same Idempotency-Key. |
Validation detail codes
| details[].code | When |
|---|---|
| REQUIRED | A required field is missing or null. |
| TOO_LONG | A text field is over its maximum length. |
| TOO_SHORT | A text field is shorter than its minimum. |
| INVALID_FORMAT | A value has the wrong type or does not match its pattern. |
| INVALID_PRIORITY | priority is not low, normal or high. |
| DATA_NOT_OBJECT | data is not a JSON object. |
| DATA_TOO_LARGE | data is over the size limit. |
| HTTPS_REQUIRED | action_url uses http. |
| INVALID_URL | action_url is not a valid https URL. |
| INVALID_TIMESTAMP | occurred_at is not an RFC 3339 time in range. |
| UNKNOWN_FIELD | The request has a field PingLoom does not know. |
Status endpoint
GET /v1/notifications/{event_id} returns the event's status and a delivery summary. It needs the events:read scope and only sees events from the key's own project. An unknown id, or one from another project, returns 404. See Status codes for the words.
Decision cards
Add a decision object to POST /v1/notifications to ask your team a question. Owners and admins answer from the app or dashboard; PingLoom records the answer and never acts on it. Read the answer back in either of two ways, both with the events:read scope:
GET /v1/notifications/{event_id}includes adecisionblock:status(pending,decided,expiredorcancelled),option_id,label,comment,decided_by(user_idandname),decided_atandvia.GET /v1/decisions?status=&since=&limit=lists your project's decisions, oldest change first, with the same block plusevent_id,external_id,channeland yourdata.limitis 1 to 100 (default 50). The response hasnext_cursorandhas_more. Sendnext_cursorback assince; the cursor is opaque, and an empty page returns the cursor you sent. A decision appears again, in its new state, after it is decided or expires.
Limits: 2 to 5 options, option id a slug up to 32 characters, label up to 40, prompt up to 200, comment up to 500, expires_in from 5 minutes to 7 days (default 24 hours). A decision raises priority low to normal. The push never carries the prompt or options. Over the plan's monthly decision limit returns 429 QUOTA_EXCEEDED; decision.policy names a decision policy (slug or id) that an owner or admin created in the dashboard (Business plan). It sets who may decide and how many different people must approve; you can name one but never define one (decision.roles returns 422). Add decision.requested_by so a policy can stop that person approving their own request. The decision then carries policy, outcome and votes. Signed-in users answer with POST /v1/events/{event_id}/decision (409 DECISION_CLOSED once someone has answered).