Skip to content

Quickstart

Five steps from a new account to your first alert on a phone.

  1. Create a project

    Sign up, create a workspace and a project in the dashboard. A project is one product or environment, such as billing-service in staging.

  2. Pick a channel

    Every project starts with a general channel that your teammates follow by default. Create more channels for noisier streams, like deploys or payments, so people can follow only what they need.

  3. Install the app and sign in

    The PingLoom app for Android and iOS is in private testing. It isn't on Google Play or the App Store yet.

    Sign in with the same account. The dashboard checklist shows your device as Ready only after PingLoom confirms the registration. Until then you can still read every event in the web inbox.

    Note: Push titles pass through Google FCM (and Apple APNs on iOS). The body never goes in the push; the app fetches it after sign-in.

  4. Create an API key

    Open the project, then API keys, then Create key. Your key is shown once. Store it as PINGLOOM_API_KEY on your server.

    Careful: Never put an API key in a mobile app or browser code.

  5. Send your first event

    Run this from your server or terminal. The Idempotency-Key header makes a retry safe: the same key with the same body returns the original event instead of creating another one.

    curl -X POST 'https://api.pingloom.dev/v1/notifications' \
      -H "Authorization: Bearer $PINGLOOM_API_KEY" \
      -H 'Idempotency-Key: demo-run-001' \
      -H 'Content-Type: application/json' \
      -d '{"channel":"general","title":"Hello from PingLoom","body":"Your integration works.","priority":"normal"}'

    Prefer a library? The Python and Node tabs above use the official SDKs (Python, Node), and the CLI tab sends from a shell (CLI). In CI, use the GitHub Action.

    PingLoom answers 202 once the event is stored:

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

    Note: Your first 20 test events from the dashboard don't count toward your quota.

    Note: Without Firebase credentials, local and staging setups use a simulated push provider, and statuses read Simulated (test mode).

Check the status

Read the event back with GET /v1/notifications/{event_id}. The key needs the events:read scope.

curl
curl 'https://api.pingloom.dev/v1/notifications/01J9ZQ8M3K7W4X2B6N5T0R1VCD' \
  -H "Authorization: Bearer $PINGLOOM_API_KEY"

StatusShown asMeaning
acceptedEvent acceptedPingLoom stored the event. Nothing has been sent yet.
queuedQueuedThe event is waiting for the worker to send it.
provider_acceptedSent to FCMFCM accepted the send for at least one device. This is not proof that a phone showed it.
simulated_acceptedSimulated (test mode)A simulated provider accepted the send. No real push was made. Local and test setups only.
retryingRetryingA send failed in a way that can recover. PingLoom tries again with a growing delay.
failedFailed to reach FCMFCM could not take the send after all attempts.
no_devicesAccepted, no active deviceThe event is stored and visible in the inbox, but no active device follows the channel.

Watch a cron job

Not every alert starts with an event you send. A heartbeat gives a cron job, backup or script its own URL to call after each run. If a call is late, PingLoom alerts your team, and tells you when it recovers. Open a project, then Heartbeats, then New heartbeat. See Heartbeats for the crontab line and the API.

Ask your team a question

Add a decision block to a normal notification to ask for an answer: two to five options, an optional comment and a deadline. An owner or admin picks an option from the app or the dashboard. The first answer wins. PingLoom records who answered and when, and your server reads the answer back. PingLoom never performs the action itself: your own code does.

POST /v1/notifications
{
  "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": "refund_partial", "label": "Refund 50%" },
      { "id": "reject", "label": "Reject" }
    ],
    "allow_comment": true,
    "expires_in": "2h"
  },
  "data": { "order_id": "8812" }
}

The push only says a decision is waiting. The question and options load in the app after sign-in. Decisions are limited by plan: a plan with none returns 429 QUOTA_EXCEEDED. If nobody answers before expires_in, the status becomes expired.

Poll one event with GET /v1/notifications/{event_id}, or poll every answer in the project with GET /v1/decisions?status=decided&since=<cursor>. Both need the events:read scope. The cursor is opaque: store next_cursor and send it back as since.

Python: wait for one answer
import os, time, requests

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


def wait_for_decision(event_id: str, poll_seconds: int = 5, give_up_seconds: int = 7200):
    """Poll until the card is decided or expires. Returns the decision dict."""
    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  # "decided" or "expired"
        time.sleep(poll_seconds)
    raise TimeoutError("nobody decided in time")


decision = wait_for_decision("01J9ZQ8M3K7W4X2B6N5T0R1VCD")
if decision["status"] == "decided" and decision["option_id"] == "refund_partial":
    issue_refund(order_id="8812", percent=50)  # your code does the action, PingLoom never does

Node: act on every answer
// Node 18+. Poll the feed of changes and act on each answer once.
const API = process.env.PINGLOOM_API_URL ?? "https://api.pingloom.example";
const headers = { Authorization: `Bearer ${process.env.PINGLOOM_API_KEY}` }; // needs events:read

let since; // keep this cursor (a file or a database row) so a restart does not replay answers
for (;;) {
  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}`);
  const page = await res.json();
  for (const { event_id, data, decision } of page.decisions) {
    await handleAnswer(event_id, data, decision.option_id, decision.comment); // must be safe to repeat
  }
  since = page.next_cursor;
  if (!page.has_more) await new Promise((r) => setTimeout(r, 5000));
}

Note: Answering takes a signed-in owner or admin. Members can read the card but not answer it. Named decision policies (Business plan) change who may answer, and a signed decision webhook (Team and Business) can replace polling.