# pingwa > WhatsApp notifications + human-in-the-loop for AI agents and developers. > The user texts "join" once; after that everything is a REST call. pingwa only > ever messages the phone that joined (self-notify by design, spam-impossible). Base URL: https://pingwa.dev ## Agent setup (do this when a human says "set up pingwa for me") Run the whole thing end-to-end. Only the WhatsApp tap needs the human. 1. Ask the human for their phone number in E.164 (e.g. +15551234567). 2. POST https://pingwa.dev/v1/registrations {"phone_e164":"..."} → save `id` and `claim_token` (shown once). See "Agent registration" below for the full field list. 3. Show the human `wa_link` (or render `qr_svg`) and tell them to tap it and send the prefilled "verify " on WhatsApp from that exact number. 4. Poll GET https://pingwa.dev/v1/registrations/ with header X-Claim-Token until status=verified. 5. POST https://pingwa.dev/v1/registrations//claim with header X-Claim-Token → returns `api_key` exactly once. 6. Store it as PINGWA_KEY. Do NOT print the key back to the human. Then wire it in: - MCP client: add the server block from "MCP server & CLI" with the header `Authorization: Bearer `, or - CLI: export PINGWA_KEY and use `uvx pingwa send`. 7. Send a test: POST https://pingwa.dev/v1/notify {"text":"pingwa is set up ✅"} and confirm the human received it on WhatsApp. ## Onboarding (human does this once) The human sends the word `join` via WhatsApp to +48797882513 (link: https://wa.me/48797882513?text=join). pingwa replies instantly with an API key (`pw_...`). Re-sending `join` rotates the key. Replying `stop` disables sending. An AI agent cannot do this step — ask your human to tap the link. ## Send a notification POST https://pingwa.dev/v1/notify Header: Authorization: Bearer pw_YOUR_KEY Body (JSON): {"text": "deploy finished"} # 1..1024 chars Optional: {"text": "build failed", "image_url": "https://.../screenshot.png"} - `image_url` (public https link WhatsApp can fetch) is sent as an IMAGE with `text` as the caption when your 24h reply window is open (e.g. a Playwright screenshot, a chart, a diff). Cold sends fall back to text only. CLI: `pingwa send --image-url URL "caption"`. Response 202: {"id": "...", "billing_class": "window_free|direct_send|template", "status": "queued"} Optional header `Idempotency-Key: ` makes retries safe: the same key (per account) replays the ORIGINAL result — same message id, no second send, no extra quota — even under a concurrent retry. Use a fresh UUID per distinct message; reuse the same key when retrying the same message. Same key + a different body → 409 idempotency_conflict. Keys are honored for 24h. Also accepted on /v1/ask. Errors are self-describing JSON with fields error/message/action: - 401 unauthorized → action explains how to get a key (send 'join') - 402 quota_exceeded → free plan is 30 paid messages/month; messages inside the user's 24h reply window are free and unlimited (user replies anything to open it) - 403 notifications_stopped → user sent 'stop'; they must send 'join' to re-enable - 503 delivery_enqueue_failed → recorded but not queued; retry shortly ## Ask a question and wait for the human's answer (two-way / human-in-the-loop) POST https://pingwa.dev/v1/ask Header: Authorization: Bearer pw_YOUR_KEY Body (JSON): {"text": "Deploy v2 to prod?", "buttons": ["Yes", "No", "Wait"], "timeout": 60} - `buttons` optional, up to 3 tap options (titles ≤20 chars). Inside the user's open 24h window they render as native WhatsApp tap buttons; otherwise the options are appended to the text and the human replies with the number or the option word — either way you get a structured `button_id` back. - Omit `buttons` for a FREE-TEXT answer: the human types whatever they want and it comes back in `reply.text`. Use this to let the human steer you mid-task ("which approach?", "what should I name it?", "anything else before I ship?"). - `timeout` optional seconds to block (default 60, max 90 — kept under the CDN's 100s limit). A timeout does NOT cancel the question. Response 200 (human answered): {"message_id","answered":true, "reply":{"answer_id","text","button_id","created_at"}} `button_id` is "b0","b1",... matching the buttons you sent (null for free text). Response 408 ask_timeout: {"message_id","action"} — the question was delivered; the human may still answer. Keep waiting with the reply endpoint below, or poll the inbox. Guards/quota are identical to /v1/notify (a paid ask costs one slot). ## Poll for the reply to a specific message GET https://pingwa.dev/v1/messages//reply?wait=60 Header: Authorization: Bearer pw_YOUR_KEY Long-polls up to `wait` seconds (max 90). Returns the same {"answered":true,"reply":{...}} shape, or 408 no_reply_yet. Works for any outbound the human quoted or button-tapped, not only asks. Use this to resume waiting after an /v1/ask 408. ## Inbox (all inbound messages, long-poll) GET https://pingwa.dev/v1/inbox?since=&wait=30&limit=20 Header: Authorization: Bearer pw_YOUR_KEY Returns {"messages":[{id, body, button_id, reply_to_message_id, wa_message_id, created_at}], "cursor": "..."}. Pass the returned `cursor` back as `since` to get only newer messages (it is a URL-safe ISO-8601 timestamp). `wait` (max 90) long-polls until a message arrives or the timeout elapses; `wait=0` (default) returns immediately. Every inbound message opens/extends the free 24h window. ## Account status GET https://pingwa.dev/v1/me → {"phone": "+*******0200", "plan": "free|pro", "active": true, "window_open": true, "usage": {"paid_used": 0, "paid_quota": 5}} DELETE https://pingwa.dev/v1/me (Bearer key) → {"deleted": true}. Right to erasure: permanently deletes the account and ALL its data (messages, usage, idempotency records). Irreversible; the key stops working immediately. To only pause sending, the human replies 'stop' on WhatsApp instead (account kept). ## Message history GET https://pingwa.dev/v1/messages?limit=20 → {"messages": [{id, direction, body, billing_class, status, expects_reply, answered_by_id, reply_button_id, error, created_at}]} ## Agent registration (get a key without the user pasting it) An agent can obtain a key end-to-end with zero human copy/paste — the human only taps a WhatsApp link and sends one message. 1. `curl -X POST https://pingwa.dev/v1/registrations -d '{"phone_e164":"+15551234567"}'` → 201 `{"id","claim_token","verify_code","wa_link","qr_svg","poll","claim","expires_at","status":"pending"}`. Save `id` and `claim_token` — the token is shown once. 2. Show the user `wa_link` (a tap-to-chat link prefilled with `verify `) or render `qr_svg`. The user taps it and sends "verify " on WhatsApp **from the phone number given as `phone_e164`**. 3. Poll `curl https://pingwa.dev/v1/registrations/ -H "X-Claim-Token: "` until the response is `{"status":"verified", "phone", "expires_at"}`. 4. `curl -X POST https://pingwa.dev/v1/registrations//claim -H "X-Claim-Token: "` → `{"api_key","phone","user_id"}`. The key is issued exactly once — store it now, it cannot be fetched again. 5. Use `api_key` as `Authorization: Bearer` on `/v1/notify` and every other endpoint above. `GET https://pingwa.dev/v1/registrations//qr.svg` (no token required) returns an SVG QR code of `wa_link`, for surfaces where a raw link isn't renderable. Registrations expire 30 minutes after creation — verified-but-unclaimed registrations expire too, so claim promptly once `status` flips to `verified`. Error responses (same self-describing error/message/action shape): - 429 `rate_limited` → back off and retry later - 409 `not_verified_yet` → user hasn't sent "verify " yet; response includes `wa_link` again so you can re-prompt - 410 `already_claimed` → `claim` was already called once for this registration - 410 `registration_expired` → 30-min TTL passed; start over with a new POST - 409 `account_exists` → this phone number already has a key; it cannot be issued a second one via registration — have the user send `join` on WhatsApp to rotate/recover their existing key instead - 422 `invalid_callback_url` → `callback_url` must be `https` and publicly reachable (no localhost/private IPs) - 403 `bad_claim_token` → wrong/missing `X-Claim-Token` header - 403 `phone_not_verified` (on `/v1/notify`) → the key's phone was never confirmed via WhatsApp verify/join; this should not happen for keys minted by this flow ## Accounts & login Password auth is an alternative to the WhatsApp-issued `pw_...` key, for building a normal login UI. It does not replace phone verification — signup is email-only, ownership of a phone number is only ever proven by WhatsApp (`verify ` above, or `join`). - `POST https://pingwa.dev/v1/auth/signup {"email","password"}` (password 8-128 chars) → `{"token","user_id","phone_verified"}`. No phone field — you get a phone by completing the registration flow above (or `join`) afterward. - `POST https://pingwa.dev/v1/auth/login {"identifier","password"}` — `identifier` is an email or `+phone` → `{"token", ...}`. - `POST https://pingwa.dev/v1/auth/password` (Bearer token) `{"password", "email?"}` — set/change password and optionally email on the logged-in account. The JWT `token` from signup/login works as a Bearer credential anywhere a `pw_...` API key does — they're interchangeable on every endpoint. ## Billing Free plan: 30 paid-route notifications/month. Pro plan: $9/mo, 500 notifications/month. Scale plan: $19/mo, 2,000 notifications/month. Switch tier or cancel anytime via Stripe. Free-window replies (within the user's 24h reply window) never count toward any quota. POST https://pingwa.dev/v1/billing/checkout[?tier=pro|pro_annual|scale] Header: Authorization: Bearer pw_YOUR_KEY Response 200: {"url": "https://checkout.stripe.com/..."} — Stripe-hosted checkout page; redirect the user (human) there to enter card details. `tier` defaults to `pro`. A paid account switches tier in the billing portal (POST /v1/billing/portal) rather than checking out again. POST https://pingwa.dev/v1/billing/portal Header: Authorization: Bearer pw_YOUR_KEY Response 200: {"url": "https://billing.stripe.com/..."} — Stripe-hosted billing portal to manage or cancel the subscription. Billing error responses (same self-describing error/message/action shape): - 503 billing_not_configured → Stripe isn't set up on this deployment - 409 already_pro → account is already on the Pro plan - 409 no_billing_account → no Stripe customer yet; call checkout first - 502 billing_provider_error → Stripe request failed; retry shortly 402 quota_exceeded (see above) also includes an `action` pointing at POST /v1/billing/checkout as the upgrade path. ## MCP server & CLI (recommended for agents) pingwa is available as an MCP server and a CLI — usually less friction than raw HTTP. CLI (ultralight, zero install): ``` export PINGWA_KEY=pw_YOUR_KEY uvx pingwa send "build finished" # notify echo "done" | uvx pingwa send - # from stdin uvx pingwa ask "Ship v2 to prod?" --button Yes --button No # wait for a tap uvx pingwa ask "What should I name the release?" # wait for free text uvx pingwa replies --wait 30 # pull out-of-band instructions your phone sent uvx pingwa me # plan / quota / window ``` Exit codes: 0 ok, 2 missing key, 3 quota exceeded, 1 other. MCP tools (same names on stdio and remote): `notify(text)`, `ask(text, buttons?, timeout?)`, `check_replies(since?, wait?)`, `check_status()`, `upgrade()`. `ask` blocks until the human answers on WhatsApp — pass `buttons` for tap options, or omit them to let the human reply with free text and steer you mid-task. Add to an MCP client — stdio: ``` {"mcpServers":{"pingwa":{"command":"uvx","args":["pingwa","mcp"], "env":{"PINGWA_KEY":"pw_YOUR_KEY"}}}} ``` or remote Streamable HTTP (no install): ``` {"mcpServers":{"pingwa":{"url":"https://pingwa.dev/mcp", "headers":{"Authorization":"Bearer pw_YOUR_KEY"}}}} ``` Tool errors carry the same `action` hints as the REST errors above, so recovery is self-explanatory. ## Notes for agents - The recipient is ALWAYS the key owner's own phone. There is no "to" field. - window_open=true means sends are free right now (user replied within 24h). - OpenAPI spec: https://pingwa.dev/openapi.json — interactive docs: https://pingwa.dev/docs - User panel (humans): https://pingwa.dev/app - Health/status: https://pingwa.dev/status.json (machine-readable) · https://pingwa.dev/status (page) - Trust: https://pingwa.dev/privacy (+ /privacy.txt) · https://pingwa.dev/about · the CLI/MCP client is open-source (MIT). - MCP server + CLI available now, including two-way `ask`/`check_replies` (see above). Roadmap: npm/npx CLI wrapper. ## Example: notify when a long task finishes curl -X POST https://pingwa.dev/v1/notify \ -H "Authorization: Bearer $PINGWA_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "task done: 3 files changed, tests green"}'