The Plow Chat API gives you a real phone number and relays your messages anywhere: send over HTTP, receive over a WebSocket, any host, any language. Write the code yourself, or hand this page to an AI agent. Not sure what to build? Give your Hermes agent a phone number builds a gateway on this API.
curl is finejq — to read the token out of the redeem responseapi.plow.coNothing is installed or self-hosted. Plow Chat is a hosted API at https://api.plow.co. Two credentials appear as you go:
| Credential | What it is |
|---|---|
| Activation secret | Short-lived and single-use. Returned when you start activation; used only to redeem for the token. |
| Bearer token | Returned once the human verifies. A user-wide credential (not chat-scoped) that authenticates every call. Treat it like an API key: never log or commit it, and store it only in chmod 600 files. |
The Hermes plow_chat gateway is one consumer of this API: it wires a Hermes agent to a Plow line so you can text the agent. To set that up, see Give your Hermes agent a phone number. The authoritative wire spec is the live openapi.json.
Ask Plow to provision a line and a chat in one call. The response gives you a display_code to text, the number to text it to (send_to), and the single-use activation_secret.
$ curl -sS -X POST https://api.plow.co/v1/auth/activate \ -H 'content-type: application/json' \ -d '{"provision_chat": true}' # → { "display_code": "ABCDE", "send_to": "+1XXXXXXXXXX", # "activation_secret": "…", "line_id": "ln_…" }
From the exact phone or iMessage identity you want connected, text this to the send_to number from the last step:
Plow Activate: ABCDE # text exactly this to the send_to number (+1XXXXXXXXXX)
This binds that messaging identity to the line. It proves you hold the phone, which is why it can’t be automated.
Plow Activate: ABCDE) and the send_to number, then wait until they confirm they’ve sent it. Only then start polling redeem in Step 4.With the code texted, poll redeem with the activation_secret until status flips to verified. The verified response returns your Bearer token and, because you asked for a chat, the chat.uid. Poll a few seconds apart.
$ RESP=$(curl -sS -X POST https://api.plow.co/v1/auth/activate/redeem \ -H 'content-type: application/json' \ -d '{"activation_secret": "…"}'); jq -r .status <<<"$RESP" # pending → keep polling, a few seconds apart # verified → $RESP now holds the token and chat.uid (printing it would log the token)
410 Gone means the code expired. It’s single-use and time-limited: start over from Step 2 and text the new code.When it flips to verified, pull both values out of the captured response — Steps 5 and 6 read them as $TOKEN and $CHAT_UID — and keep a durable copy of the token at mode 600. Extracted this way, the token is never typed, so it never lands in your shell history:
$ export TOKEN=$(jq -r .token <<<"$RESP") CHAT_UID=$(jq -r .chat.uid <<<"$RESP") $ (umask 177; printf '%s\n' "$TOKEN" > ~/.plow-token)
Post the message body to the chat. Any 2xx means it was accepted; the live API returns 201.
$ curl -sS -X POST https://api.plow.co/v1/chats/$CHAT_UID/messages \ -H "authorization: Bearer $TOKEN" \ -H 'content-type: application/json' \ -d '{"body": "hello from the Plow API"}' # 201 Created → sent. 409 chat_not_ready → not verified yet, retry shortly.
Mint a short-lived WebSocket ticket, then connect and listen. Inbound texts arrive as message_received frames.
$ curl -sS -X POST https://api.plow.co/v1/ws/ticket \ -H "authorization: Bearer $TOKEN" \ -H 'content-type: application/json' \ -d "{\"chat_id\": \"$CHAT_UID\"}" # → { "ticket": "…" } then connect: # wss://api.plow.co/v1/ws?ticket=<ticket>
Connect with any WebSocket client — Step 7’s script is the runnable version of this step — and leave it listening.
direction: "outbound". Act only on direction: "inbound", or your agent will reply to itself. On disconnect, mint a fresh ticket and backfill missed messages with GET /v1/chats/{chat_uid}/messages.A minimal Python quickstart: given a token and chat id, it sends one message, then streams inbound replies. It works, but it’s the happy path only. You own the hardening.
#!/usr/bin/env python3 # Minimal Plow Chat quickstart: send one message, then stream inbound replies. # deps: pip install websockets (all HTTP uses the standard library) import asyncio, json, os, urllib.request import websockets BASE = "https://api.plow.co" TOKEN = os.environ["PLOW_TOKEN"] CHAT_UID = os.environ["PLOW_CHAT_UID"] AUTH = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"} def post(path, body): req = urllib.request.Request( f"{BASE}{path}", data=json.dumps(body).encode(), method="POST", headers=AUTH) with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) async def main(): # send one message (any 2xx = success; the live API returns 201) sent = post(f"/v1/chats/{CHAT_UID}/messages", {"body": "hello from the quickstart"}) print("sent:", sent.get("uid")) # receive: mint a short-lived ticket, then connect the WebSocket ticket = post("/v1/ws/ticket", {"chat_id": CHAT_UID})["ticket"] async with websockets.connect(f"wss://api.plow.co/v1/ws?ticket={ticket}") as ws: async for raw in ws: frame = json.loads(raw) if frame.get("type") != "message_received": continue msg = frame["message"] if msg.get("direction") == "outbound": # echo of our own send - skip continue print(f"{msg['sender'].get('display_name', '?')}: {msg['body']}") if __name__ == "__main__": asyncio.run(main())
The only third-party dependency is pip install websockets; everything else is the standard library. Python 3.8+. Run it with a token and chat id in the environment:
$ PLOW_TOKEN=… PLOW_CHAT_UID=cht_… python3 plow_quickstart.py
chat_active, participant_verified, …)409 chat_not_ready, 410 expired, non-2xx sends)chmod 600 file instead of an env varEverything the steps above call, plus the frames you’ll see on the WebSocket.
| Endpoint | Does |
|---|---|
POST /v1/auth/activate | Begin activation. Returns display_code, send_to, activation_secret, line_id. provision_chat:true also creates the first chat. |
POST /v1/auth/activate/redeem | Poll with the secret until verified. Returns token (+ chat.uid). 410 if expired. |
GET /v1/lines | List provisioned lines (ln_… uid + provider_key = the sender number). |
POST /v1/chats · GET /v1/chats/{uid} | Create / read chats. Lifecycle pending → active; terminal failed (recover by delete + recreate). |
POST /v1/chats/{uid}/messages | Send a message ({body}). Success = any 2xx. |
GET /v1/chats/{uid}/messages | Read history — used to backfill after a WebSocket reconnect. |
POST /v1/ws/ticket | Mint a short-lived ticket for wss://api.plow.co/v1/ws?ticket=…. |
| Frame | Meaning |
|---|---|
connected | Stream is up. |
chat_active | The chat verified and is live (good moment to send a welcome). |
participant_verified | A member confirmed by text. |
message_received | A message. Body in message.body; act only on direction: "inbound". |
message_status_updated | Delivery/read status change. |
chat_activation_failed | Activation failed — terminal for that chat. |
Read once before you start.
| Edge | What to do |
|---|---|
| The token is a user-wide key | It’s not chat-scoped; it can read your identity and channels. Treat like an API key: chmod 600, never log, never commit. |
| Success is any 2xx | The live API returns 201 on send/ticket, not 200. Check for 2xx, not an exact code. |
410 on redeem = expired code | Activation codes are single-use and time-limited. Re-run Step 2 and text the new code; it’s expected, not an error state. |
| Ignore your own echoes | The WebSocket echoes outbound messages. Filter to direction: "inbound" or you’ll loop. |
| Reconnect + backfill | Tickets are short-lived and the socket can drop. On disconnect, mint a fresh ticket and backfill with GET /v1/chats/{uid}/messages. |
| Pre-1.0 | The API may change. The live openapi.json is authoritative if anything here drifts. |
Nothing is installed on a host, so there’s nothing to uninstall. The only thing that persists is the credential.
The Bearer token is user-wide, so removing your copy is the cleanup. The exported TOKEN is gone when the shell closes; delete the durable copy Step 4 wrote:
$ rm -f ~/.plow-token # remove the saved Bearer token
To retire the chat itself, soft-delete it through the API; the line returns to the pool. Success is 204 No Content.
$ curl -sS -X DELETE https://api.plow.co/v1/chats/$CHAT_UID \ -H "authorization: Bearer $TOKEN" # 204 No Content → deleted. 404 → already gone or not yours.
If you suspect the token leaked, rotate it: start over from Step 2 to mint a fresh one.