# PingLoom integration guide for AI agents

PingLoom is notification-as-a-service. A server calls `POST /v1/notifications` with a project API key. PingLoom stores the event, then a worker sends a minimal push through FCM to the phones of people who follow the channel. The full message is fetched by the app after sign-in and also appears in a shared team inbox on the web.

API base URL: `https://api.pingloom.dev`
Site and docs: `https://pingloom.dev`

Use this file as the source of truth when adding PingLoom to a user's project.

## Rules you must follow

1. The API key is a server secret. Keys start with `pl_test_` outside production and `pl_live_` in production. Read it from the environment variable `PINGLOOM_API_KEY`. Never put it in browser code, a mobile app, a `NEXT_PUBLIC_*` or `VITE_*` variable, or git. Never ask the user to paste it into chat.
2. Client code never calls PingLoom. Client code calls the user's own server, which calls PingLoom. For a static site with no server, add a small serverless function (a relay) that holds the key.
3. Never call a notification "delivered". PingLoom confirms what FCM accepted, not that a phone showed an alert. The status word is `provider_accepted`.
4. Do not put secrets or personal data in `title`, `body` or `data`: pushes pass through Google FCM.
5. A failed notification must never break the user's own request. Catch and log errors (code, message, request_id only; never the key).

## 1. Detect the stack

Check `package.json` (Node, Next.js, Express, Nest), `pyproject.toml` or `requirements.txt` (Python, FastAPI, Django, Flask), `vercel.json` or `netlify.toml` (serverless), or plain HTML/JS with no server (static site). Match the project's language and style. Node 18+ has `fetch`; do not add an HTTP library only for this.

## 2. Get a key

The user creates a project and a key in the dashboard: open the project, API keys, Create key. The key is shown once. It needs the `notifications:send` scope to send and `events:read` to check status. They store it as `PINGLOOM_API_KEY` in the server environment. Add `PINGLOOM_API_KEY=` (empty) to `.env.example` and make sure `.env` is git-ignored. Also read `PINGLOOM_API_URL`, defaulting to `https://api.pingloom.dev`.

## 3. Send a notification

Request:

```
POST /v1/notifications
```

Headers: `Authorization: Bearer $PINGLOOM_API_KEY`, `Content-Type: application/json` (anything else returns 415), optional `Idempotency-Key`.

```bash
curl -X POST "https://api.pingloom.dev/v1/notifications" \
  -H "Authorization: Bearer $PINGLOOM_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-paid" \
  -d '{"channel":"payments","title":"Payment received: $49.00","body":"Order 1042 was paid.","priority":"normal"}'
```

Body example with every common field:

```json
{
  "channel": "payments",
  "title": "Payment received: $49.00",
  "body": "Order 1042 was paid.",
  "priority": "normal",
  "event_type": "order.paid",
  "external_id": "order-1042",
  "data": { "order_id": "1042" },
  "action_url": "https://example.com/orders/1042",
  "occurred_at": "2026-10-24T09:30:00Z"
}
```

Fields (only `title` is required):

| Field | Rule |
| --- | --- |
| `title` | 1 to 120 characters, no control characters |
| `body` | up to 2,000 characters |
| `channel` | slug matching `^[a-z0-9][a-z0-9-]{0,39}$`, default `general`. A well-formed slug that does not exist in the project returns 422 `INVALID_CHANNEL` |
| `priority` | `low`, `normal` or `high`, default `normal` |
| `event_type` | optional, matches `^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$` |
| `external_id` | optional, 1 to 200 characters, your own reference |
| `data` | optional JSON object, at most 8 KB (8,192 bytes compact); not sent in the push |
| `action_url` | optional absolute `https` URL, up to 2,048 characters, no credentials; PingLoom never fetches it |
| `decision` | optional object that asks the team a question, see section 10 |
| `occurred_at` | optional RFC 3339 time with offset, within 365 days back and 1 day ahead |

The whole request body is at most 32 KB. Any other field returns 422 `UNKNOWN_FIELD`.

Success is `202 Accepted`, returned only after the event is stored:

```json
{
  "event_id": "01J9ZQ8M3K7W4X2B6N5T0R1VCD",
  "status": "accepted",
  "created_at": "2026-10-24T09:30:00Z",
  "status_url": "/v1/notifications/01J9ZQ8M3K7W4X2B6N5T0R1VCD",
  "idempotent_replay": false,
  "deduplicated": false
}
```

### Idempotency

`Idempotency-Key` is 1 to 255 printable ASCII characters. Derive it from the business event (`order-1042-paid`), not a random value, so retries are safe. The same key with the same body within 24 hours returns the original event (`idempotent_replay` is true, header `Idempotent-Replayed: true`) and is not counted again. The same key with a different body returns 409 `IDEMPOTENCY_CONFLICT`: that is a caller bug, do not retry.

## 4. Errors

Every error uses this envelope:

```json
{
  "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 | Retry? |
| --- | --- | --- |
| 400 | `INVALID_JSON`, `INVALID_IDEMPOTENCY_KEY` | no, fix the request |
| 401 | `INVALID_API_KEY` | no, the key is missing, unknown, revoked or expired |
| 403 | `INSUFFICIENT_SCOPE` | no, the key needs `notifications:send` |
| 409 | `IDEMPOTENCY_CONFLICT`, `PROJECT_ARCHIVED` | no |
| 413 | `PAYLOAD_TOO_LARGE` | no, body over 32 KB |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | no, send `Content-Type: application/json` |
| 422 | `VALIDATION_ERROR`, `INVALID_CHANNEL` | no, see `details[]` |
| 429 | `RATE_LIMITED` | yes, wait the `Retry-After` header |
| 429 | `QUOTA_EXCEEDED` | no, the monthly quota is used; resets at `X-PingLoom-Quota-Reset` |
| 5xx | `INTERNAL_ERROR` | yes, with the same `Idempotency-Key`; nothing was counted |

## 5. The helper

Write one small helper in the project's language. Node (ESM, no dependencies):

```js
// pingloom.js - server side only
const API_URL = process.env.PINGLOOM_API_URL ?? "https://api.pingloom.dev";

export async function notify({ title, body, channel = "general", priority = "normal", data, actionUrl, idempotencyKey }) {
  const key = process.env.PINGLOOM_API_KEY;
  if (!key) {
    console.warn("PINGLOOM_API_KEY is not set; skipping notification");
    return null;
  }
  const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
  const payload = { title, body, channel, priority, data, action_url: actionUrl };
  for (let attempt = 0; attempt < 3; attempt++) {
    let res;
    try {
      res = await fetch(`${API_URL}/v1/notifications`, {
        method: "POST",
        headers,
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(5000),
      });
    } catch (err) {
      if (attempt === 2) throw err;
      continue;
    }
    if (res.status === 202) return res.json();
    const { error } = await res.json().catch(() => ({}));
    if (res.status === 429 && error?.code === "RATE_LIMITED") {
      const wait = Number(res.headers.get("Retry-After") ?? 1);
      await new Promise((r) => setTimeout(r, Math.min(wait, 10) * 1000));
      continue;
    }
    if (res.status >= 500 && attempt < 2) continue;
    throw new Error(`PingLoom ${res.status} ${error?.code ?? ""} ${error?.message ?? ""} (request_id ${error?.request_id ?? "?"})`);
  }
}
```

Python:

```python
# pingloom.py - server side only
import os
import time
import requests

API_URL = os.environ.get("PINGLOOM_API_URL", "https://api.pingloom.dev")


def notify(title, body=None, channel="general", priority="normal", data=None, action_url=None, idempotency_key=None):
    key = os.environ.get("PINGLOOM_API_KEY")
    if not key:
        print("PINGLOOM_API_KEY is not set; skipping notification")
        return None
    headers = {"Authorization": f"Bearer {key}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    payload = {"title": title, "body": body, "channel": channel, "priority": priority, "data": data, "action_url": action_url}
    payload = {k: v for k, v in payload.items() if v is not None}
    for attempt in range(3):
        try:
            res = requests.post(f"{API_URL}/v1/notifications", json=payload, headers=headers, timeout=5)
        except requests.RequestException:
            if attempt == 2:
                raise
            continue
        if res.status_code == 202:
            return res.json()
        error = (res.json() if res.content else {}).get("error", {})
        if res.status_code == 429 and error.get("code") == "RATE_LIMITED":
            time.sleep(min(float(res.headers.get("Retry-After", 1)), 10))
            continue
        if res.status_code >= 500 and attempt < 2:
            continue
        raise RuntimeError(f"PingLoom {res.status_code} {error.get('code')} {error.get('message')} (request_id {error.get('request_id')})")
```

In a request handler, do not `await` the helper before responding to the user; call it and catch and log failures.

## 6. Static sites: a serverless relay

If there is no server, add a function (Vercel, Netlify, Cloudflare) that holds `PINGLOOM_API_KEY` as a server env var. It must accept only `POST`, accept only a fixed allow-list of event names, build `title` and `body` itself (never forward text from the browser), check that the `Origin` header is the site's own, and cap volume, for example by using a fixed `Idempotency-Key` per hour bucket so repeats are not metered. The browser calls the relay with the event name only.

## 7. Choose which events to send

Scan the codebase for moments a human would want to know about: payment succeeded or failed, new signup, deploy finished or failed, background job failed, error spike, queue backlog, security events. Propose a short list and let the user choose. Use one channel per stream (`payments`, `deploys`, `errors`). Use `high` priority sparingly.

For high-volume events (page views, every request, every signup on a busy product) do not send one notification per event: it buzzes phones and burns the monthly quota. Send an hourly or daily summary ("Yesterday: 412 signups, 58 orders") or only the first event past a threshold. Never include text the end user typed.

## 8. Verify

1. With the key in the server env, send one test event through the new helper.
2. Expect 202 with an `event_id` and `status` `accepted`.
3. Read it back with a key that has the `events:read` scope:

```
GET /v1/notifications/{event_id}
```

```bash
curl "https://api.pingloom.dev/v1/notifications/$EVENT_ID" -H "Authorization: Bearer $PINGLOOM_API_KEY"
```

The response has `event_id`, `project_id`, `channel`, `status`, `created_at`, `external_id`, a `delivery_summary` object (counts of recipients and devices per delivery state), `acknowledged`, `acknowledged_at` and `dead_lettered_at`. An unknown id, or one from another project, returns 404.

Status words:

| `status` | Meaning |
| --- | --- |
| `accepted` | PingLoom stored the event. Nothing is sent yet. |
| `queued` | Waiting for the worker. |
| `provider_accepted` | FCM accepted the send for at least one device. Not proof that a phone showed it. |
| `simulated_accepted` | A simulated provider accepted it. No real push was made (local and test setups). |
| `no_devices` | Stored and visible in the inbox, but no active device follows the channel. |
| `retrying` | A send failed in a recoverable way; PingLoom retries with backoff. |
| `failed` | FCM could not take the send after all attempts. |

Report the result in these words. Tell the user what you did not verify, such as whether a real phone showed the alert.

## 9. Heartbeats: alert when a job goes quiet

Use a heartbeat for cron jobs, backups and scripts that should run on a schedule. The job calls a private URL after each run. If a call is late beyond a grace period, PingLoom sends a normal alert to the project's channel, and a "back" alert when pings resume. Pings do not count toward the monthly event quota; the alerts they cause do.

Create one in the dashboard (Project, Heartbeats) or with a signed-in user session through the management API:

```
POST /v1/projects/{project_id}/heartbeats
```

```json
{ "name": "Nightly backup", "period_seconds": 86400, "grace_seconds": 900, "channel_id": "<channel id>" }
```

The 201 response has a `heartbeat` object and a `ping_url`. The URL is shown once and cannot be fetched again; `POST /v1/projects/{project_id}/heartbeats/{heartbeat_id}/rotate-token` makes a new one and stops the old one working at once. Treat the URL as a secret: store it in the job's environment, never in the repository, and never in a browser or mobile app.

Call it from the job (GET or POST both work, the first 1 KB of a POST body is kept in the ping history):

```
GET /v1/heartbeats/{token}
GET /v1/heartbeats/{token}/start
GET /v1/heartbeats/{token}/fail
```

```bash
/path/to/job.sh && curl -fsS -m 10 --retry 3 "$HEARTBEAT_URL" > /dev/null
```

Each call returns `200` with `{"ok": true}`. `/start` marks the beginning of a run, `/fail` raises a high-priority alert immediately. An unknown or rotated token returns `404`. Calls are rate limited per URL (60 a minute) and per IP address. The number of heartbeats is limited by the plan; creating past it returns `409` `PLAN_LIMIT_REACHED`.

Heartbeat alerts have the event types `heartbeat.down`, `heartbeat.fail` and `heartbeat.up` and `data.heartbeat_id`. Report them in the usual status words.

## 10. Decision cards: ask the team a question

Use a decision when your server needs a person to choose, such as "refund this order?". Add a `decision` object to a normal `POST /v1/notifications`. An owner or admin picks one option from the app or the dashboard (members can only read it). The first answer wins. PingLoom records who answered and never performs the action: your own code does, after it reads the answer. The push says only that a decision is waiting; the question and options load in the app.

```json
{
  "title": "Refund request: 4,500",
  "channel": "billing",
  "priority": "high",
  "decision": {
    "prompt": "What should we do with order #8812?",
    "options": [
      { "id": "refund_full", "label": "Refund full amount" },
      { "id": "reject", "label": "Reject" }
    ],
    "allow_comment": true,
    "expires_in": "2h"
  },
  "data": { "order_id": "8812" }
}
```

Rules: `prompt` up to 200 characters; `options` 2 to 5 items of `{id, label}` (id a slug `^[a-z0-9][a-z0-9_-]{0,31}$`, unique; label up to 40 characters); `allow_comment` boolean (comment up to 500 characters); `expires_in` like `2h`, `30m`, `1d` or `PT2H`, from 5 minutes to 7 days, default 24 hours. `decision.policy` names a decision policy (slug or id) that an owner or admin created in the dashboard under Project, Decision policies (Business plan). The policy sets who may decide, how many different people must approve, whether one reject ends it and what happens when nobody answers. You can only name a policy: `decision.roles` and any inline rule return 422, so a stolen API key can't make itself the approver. `decision.requested_by` (a user id or email of a workspace member) lets the policy stop that person approving their own request; a non-member returns 422. An unknown, archived or other-workspace policy returns 422, and a plan without named policies returns 403 `PLAN_FEATURE_REQUIRED`. With a policy, the `decision` object also carries `policy {id, version, approvals_required}`, `outcome {option_id, label}` and `votes[]` (`user_id`, `role`, `option_id`, `comment`, `note`, `at`, `via`); `status` stays `pending`, `decided`, `expired` or `cancelled`, and `expired_as` is `rejected` when the policy says an expired decision counts as rejected. Safeguards for important decisions: `decision.confirm_twice`, `decision.require_note` and `decision.require_device_unlock` (booleans, default false) can be set per card or on a named policy; a card can only switch one on, never off a policy's setting. With `confirm_twice`, the apps show a second screen before the vote counts, and any client that votes through the API must send `confirmed: true` or gets 422 `CONFIRMATION_REQUIRED`. With `require_note`, a vote needs a `note` (up to 500 characters) or gets 422 `NOTE_REQUIRED`; the note is returned on the decision (`note` on the deciding vote, `votes[].note`) and in the signed webhook, and is never written to logs. `require_device_unlock` makes the mobile app ask for fingerprint, face or PIN before it sends the vote; that check happens on the phone only and the server cannot verify it. A decision raises priority `low` to `normal`. A plan has a monthly allowance of decisions; past it, or on a plan with none, the send returns 429 `QUOTA_EXCEEDED` and nothing is stored.

Read the answer back by polling, needs the `events:read` scope. `GET /v1/notifications/{event_id}` returns a `decision` object:

```json
{
  "status": "decided",
  "option_id": "refund_full",
  "label": "Refund full amount",
  "comment": "Repeat buyer",
  "decided_by": { "user_id": "01J9ZQ...", "name": "Priya" },
  "decided_at": "2026-11-04T10:22:31Z",
  "via": "mobile"
}
```

`status` is `pending`, `decided`, `expired` (nobody answered in time) or `cancelled`. `GET /v1/decisions?status=decided&since=<cursor>&limit=50` returns the project's decisions, oldest change first, as `{decisions, next_cursor, has_more}`. Store `next_cursor` and send it back as `since`; the cursor is opaque. Poll every few seconds to a minute, act on each answer at most once (key it on `event_id`), and treat `expired` as "no answer". Python:

```python
import os, time, requests

API = os.environ.get("PINGLOOM_API_URL", "https://api.pingloom.dev")
HEADERS = {"Authorization": f"Bearer {os.environ['PINGLOOM_API_KEY']}"}

def wait_for_decision(event_id, poll_seconds=5, give_up_seconds=7200):
    deadline = time.monotonic() + give_up_seconds
    while time.monotonic() < deadline:
        r = requests.get(f"{API}/v1/notifications/{event_id}", headers=HEADERS, timeout=10)
        r.raise_for_status()
        decision = r.json()["decision"]
        if decision["status"] != "pending":
            return decision
        time.sleep(poll_seconds)
    raise TimeoutError("nobody decided in time")
```

Node 18+:

```js
const API = process.env.PINGLOOM_API_URL ?? "https://api.pingloom.dev";
const headers = { Authorization: `Bearer ${process.env.PINGLOOM_API_KEY}` };

async function pollDecisions(since) {
  const url = new URL("/v1/decisions", API);
  url.searchParams.set("status", "decided");
  if (since) url.searchParams.set("since", since);
  const res = await fetch(url, { headers });
  if (!res.ok) throw new Error(`PingLoom answered ${res.status}`);
  return res.json(); // { decisions, next_cursor, has_more }
}
```

Never put the API key in a browser or mobile app; people answer through the signed-in app or dashboard, not through the API key. Rules for who may answer and a signed webhook instead of polling are not available yet; do not promise them.

## Reference links

- API reference: `https://pingloom.dev/docs/api`
- Status codes: `https://pingloom.dev/docs/status-codes`
- Heartbeats: `https://pingloom.dev/docs/heartbeats`
- OpenAPI: `https://pingloom.dev/openapi/pingloom-server-v1.json`
