Recipe · Personal command-bus

Text your infrastructure. It texts back.

Message your pingwa number — or send a voice note — and your own code answers. pingwa POSTs a signed webhook to your handler, your handler does the thing, and it replies over the same chat. No app, no linked device, no dashboard to open; a WhatsApp thread with your servers. Replies inside the 24h window are free, so a whole back-and-forth with your infra costs nothing.

§ 1

The whole loop

One direction is the notify path you already know. This page is the inbound half — you talk first, your code reacts. Register an HTTPS endpoint once with POST /v1/webhooks; from then on every message you send is pushed to it.

you (phone) ──▶ your pingwa number ──▶ pingwa ──POST──▶ your handler (signed: X-Pingwa-Signature)
your handler ──do the thing──▶ POST /v1/notify ──▶ reply lands in your chat (free — window is open, you just messaged)

Registering prints a signing secret — whsec_…once. Store it; it signs every delivery and is the only way to know a request is really from pingwa and not a stranger who found your URL.

§ 2

The handler (FastAPI, ~20 lines)

Verify the signature over the raw body bytes with hmac.compare_digest, then dispatch on the first word. Here note … appends a line to a file and status reports uptime back into the chat. The reply is a plain POST /v1/notify — free, because your own message just opened the 24h window.

hook.py — the command-bus
import hmac, hashlib, os, subprocess, httpx
from fastapi import FastAPI, Request, HTTPException

SECRET = os.environ["PINGWA_WHSEC"].encode()   # the whsec_... from registration
KEY = os.environ["PINGWA_KEY"]                  # pw_... to send replies
app = FastAPI()

def reply(text):
    httpx.post("https://pingwa.dev/v1/notify",
               headers={"Authorization": f"Bearer {KEY}"},
               json={"text": text})

@app.post("/pingwa-hook")
async def hook(request: Request):
    raw = await request.body()   # EXACT bytes — do not re-parse then re-dump
    want = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    got = request.headers.get("X-Pingwa-Signature", "")
    if not hmac.compare_digest(want, got):
        raise HTTPException(401)          # reject anything that fails
    body = (await request.json()).get("body") or ""
    cmd, _, rest = body.strip().partition(" ")
    if cmd == "note":
        open("inbox.txt", "a").write(rest + "\n")
        reply("noted ✍️")
    elif cmd == "status":
        reply("up " + subprocess.check_output(["uptime", "-p"]).decode().strip())
    return {"ok": True}               # any 2xx acknowledges the delivery

The inbound payload is {"event":"inbound_message","message_id","body","button_id", "reply_to_message_id","wa_message_id","window_open","created_at"}. body is your text; window_open tells you the reply is free right now (it always is, straight after you messaged). Return any 2xx or pingwa retries with backoff — after three exhausted deliveries it deactivates the webhook, so keep the handler honest.

§ 3

Voice notes

Send a voice message instead of typing and the payload gains a media object; body is empty for a pure voice note. pingwa forwards the audio — transcription is on your side, so you own the model and the words never leave your box.

payload shape + fetch → transcribe
# the media object on a voice message:
# {"kind":"audio","voice":true,"mime_type":"audio/ogg; codecs=opus",
#  "url":"https://.../v1/media/<message_id>?exp=&sig="}

media = payload.get("media")
if media and media["kind"] == "audio":
    # the signed url authorizes the fetch — no api key needed — but it is
    # short-lived (~1h), so pull the bytes right away, don't queue it for later
    audio = httpx.get(media["url"]).content
    open("note.ogg", "wb").write(audio)
    # transcribe on your side (openai-whisper's Python API returns the text;
    # the CLI writes it to a file instead — whisper.cpp works the same way):
    import whisper
    text = whisper.load_model("base").transcribe("note.ogg")["text"]
    # then treat the transcript exactly like a typed command (dispatch as § 2)

Honest about the boundary: pingwa hands you the raw audio/ogg opus bytes and stops there. Whether you run local whisper.cpp, the openai-whisper package, or a cloud STT is your call — the recipe just shows the seam where the transcript becomes a command.

§ 4

GTD capture — message yourself

The command-bus doubles as a capture inbox. Anything you text that isn't a command falls through to a catch-all that appends it to a todo file — a thought from a walk, a link, a "buy milk", captured from anywhere your phone has signal, in two taps. No app to open, no context to switch into; the chat you already have with your infra is the inbox.

the fall-through, added to § 2's dispatch
KNOWN = {"note", "status"}
if cmd not in KNOWN:
    # not a command → it's a capture; the whole line goes to the inbox
    open("inbox.txt", "a").write(body.strip() + "\n")
    reply("📥 captured")

Voice-note capture is the same seam plus § 3: talk into your phone, whisper turns it into a line, the line lands in inbox.txt. Frictionless capture is the whole point of GTD — this is the shortest path to it that still runs on your own hardware.

Every message you send resets the free 24h window, and replies inside that window are free and unlimited. So an entire conversation with your infrastructure — ask, answer, follow-up, answer — costs nothing. The paid meter only ticks when your code taps you unprompted outside the window; a bus you drive by texting first stays free.

§ 5

FAQ

WhatsApp webhook automation — how does it work here?

Register an HTTPS endpoint with POST /v1/webhooks and pingwa POSTs every inbound message to it as {"event":"inbound_message","body",…}. Your handler verifies the X-Pingwa-Signature header (HMAC-SHA256 over the raw body), dispatches on the text, and replies with POST /v1/notify. That is the whole automation — § 2 is the complete handler, no framework of ours to learn.

Can I text my server / run commands from WhatsApp?

Yes — that's the recipe. Your first word is the command (note, status, or whatever you define) and your code decides what it does: run a script, restart a service, query a database, kick off a deploy. Keep the command set small and explicit, and remember the signature check in § 2 is what stops anyone but pingwa from reaching that switch.

Voice note to automation — does pingwa transcribe it?

No — pingwa forwards the audio, transcription is on your side. A voice message adds a media object with a signed, ~1h URL; you GET the audio/ogg opus bytes and pipe them to whisper (local whisper.cpp or the openai-whisper package) yourself, then treat the transcript as a typed command. That keeps the words on your box and lets you pick the model — see § 3.

How do I verify a delivery is really from pingwa?

Recompute "sha256=" + HMAC-SHA256(secret, raw_body).hexdigest() and compare it to the X-Pingwa-Signature header with hmac.compare_digest. Reject anything that fails. The secret is the whsec_… shown once when you registered the webhook — it never appears in a payload, so a request that carries a valid signature could only have come from someone who holds it. Compute the HMAC over the exact raw bytes; re-parsing and re-serializing the JSON will change them and break the match.

QR code to join pingwa on WhatsApp

Scan the code and get instant access. It opens WhatsApp with “join” ready to send, and your API key comes straight back.

The handler above is the honest shape of the integration, not lab-verified for every framework and STT stack — pin your versions, keep the command set small, and test the signature check before you wire anything destructive behind a text message.