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: this is the solo, one-to-one path, and the walkthrough rides it end to end. The response gives you a display_code to text, the number to text it to (send_to), and the single-use activation_secret. Group chats ride the same activation — see Group chats once you hold the token.
$ 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. It is also the identity Plow recognizes later when you start a group chat.
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 → the chat isn’t active, retry shortly.
body is markdown. Plow converts it at the edge, so bold, italic, strikethrough, headings and lists arrive as real iMessage styling — as do :shake[…]-style animation directives.
$ curl -sS -X POST https://api.plow.co/v1/chats/$CHAT_UID/messages \ -H "authorization: Bearer $TOKEN" \ -H 'content-type: application/json' \ -d '{"body": "**Deploy done** — *3 min*, :shake[zero] errors"}' # → styled on iMessage. SMS and RCS recipients see it plain.
Reads speak markdown too: GET /v1/chats/{uid}/messages and the message_received frame hand body back as markdown, so a fetched body posts straight back unchanged. That’s the whole markdown story — raw decoration ranges, byte-literal sends and screen/bubble effects live in the openapi.json.
Show the typing bubble in the thread while you compose, and clear it with stop. Sending a message clears it too, so a stop after a send is unnecessary.
$ curl -sS -X POST https://api.plow.co/v1/chats/$CHAT_UID/typing \ -H "authorization: Bearer $TOKEN" \ -H 'content-type: application/json' \ -d '{"action": "start"}' # → { "ok": true, "action": "start" } Send "stop" to clear it.
It clears itself after about 85–90 seconds and nothing keeps it alive for you, so to hold it up, call start again about every 60 seconds. It’s iMessage only, and it doesn’t work in group chats — those return 424 provider_typing_failed. Even on a 200 it may not reach the recipient if nothing has been sent in the chat for the last few minutes; once you’ve sent one, it’s reliable. The rest is in the openapi.json.
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
connected, message_status_updated)409 chat_not_ready, 410 expired, non-2xx sends)chmod 600 file instead of an env varEverything the steps above call, the group-chat recipe, and the frames you’ll see on the WebSocket.
| Endpoint | Does |
|---|---|
POST /v1/auth/activate | Begin activation. Always returns display_code, send_to, activation_secret. With provision_chat:true it also assigns the solo chat line and returns its line_id; without it, send_to is the shared pairing number and no chat is created. |
POST /v1/auth/activate/redeem | Poll with the secret until verified. Returns token, plus chat.uid on the solo path only. 410 if expired. |
GET /v1/lines | List provisioned lines. The response is an envelope, not an array: read .data[0].uid for the line id and .data[0].provider_key for its number (there is no .lines property). |
GET /v1/chats · GET /v1/chats/{uid} | List / read chats. There is no create call: a chat is made for you by activation (solo) or by the first group text (Group chats), and arrives active. Same envelope as /v1/lines. |
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. Newest first, cursor-paginated with limit and starting_after=<message uid>; the envelope’s has_more says whether another page exists. |
POST /v1/chats/{uid}/typing | Show ({"action":"start"}) or clear ({"action":"stop"}) the typing bubble. iMessage only; a group chat returns 424. |
POST /v1/ws/ticket | Mint a short-lived ticket for wss://api.plow.co/v1/ws?ticket=…. |
There is no create call, and no codes for anyone to text. A group chat is born from the group text itself: add your Plow number — the send_to number from Step 2 — to a native group thread and send any message from the phone you activated with. Plow recognizes you, creates the chat with everyone in the thread, and replies 👋. Everything in this guide works on a group chat except the typing indicator, which returns 424 here.
hey everyone # in a group thread that includes your Plow number (+1XXXXXXXXXX) # Plow replies 👋 in the thread — that’s the chat, created
Nothing announces the new chat over the API, so pick it up from the list — it’s there the moment the 👋 lands:
$ CHATS=$(curl -sS https://api.plow.co/v1/chats -H "authorization: Bearer $TOKEN") $ export CHAT_UID=$(jq -r '.data[-1].uid' <<<"$CHATS") # oldest first, so the newest is last # → { "object": "list", "data": [ { "uid": "cht_…", "status": "active", # "participants": [ {"type": "agent", …}, {"type": "member", …} ] } ], … }
Steps 5 and 6 then work unchanged with that CHAT_UID. Membership is whatever the messaging provider reports for the thread, and the roster grows on its own as quiet members first speak — you’ll see a message_received frame from a sender no earlier frame mentioned.
GET /v1/chats a few seconds apart until the new cht_ uid appears.| Frame | Meaning |
|---|---|
connected | Stream is up. |
message_received | A message. Body in message.body; act only on direction: "inbound". The sender may be one you’ve never seen before — group rosters grow as members speak. |
message_status_updated | Delivery/read status change. |
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. |
has_more means another page | Every list envelope carries it. On /v1/chats/{uid}/messages, keep paging with starting_after=<the last uid you saw> until it is false. A backfill that reads one page and stops loses the oldest messages, since history comes back newest first — and nothing errors, so the gap is silent. /v1/chats is not paginated and always returns false. |
Poll redeem on pending only | Keep polling while redeem returns 200 with status: pending. Surface persistent 4xx/5xx responses instead of treating them as pending; only 410 has a scripted recovery (restart activation). |
| Chats appear on their own | Nothing you call creates one, and no frame announces one. Poll GET /v1/chats to find a group chat after the 👋 lands. |
| 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.