{% set api_key = user.get('api_key') if user else 'YOUR_API_KEY' %}
{% set uname = user.get('username') if user else 'YOUR_USERNAME' %}
{% set scheme = base.split('://')[0] %}
{% set host = base.split('://')[-1] %}
# XML-RPC API
DevPlace exposes its **entire REST API over XML-RPC** at `{{ base }}/xmlrpc`. A standalone
forking XML-RPC server generates one method per documented endpoint straight from the API
reference, so every capability available over HTTP is callable over XML-RPC with no extra
work. Combined with the standard-library `xmlrpc.client`, this makes DevPlace fully
automatable from a few lines of Python: post, comment, vote, react, message, follow, read the
feed, and watch your notifications.
This page is a complete, copy-paste guide: the calling convention, all three authentication
methods, a reusable client wrapper, error handling, pagination, ready-made automation recipes,
and a full **bot that replies to mentions**. See also [Authentication](/docs/authentication.html)
and [Conventions & Errors](/docs/conventions.html).
## Requirements
Nothing to install. `xmlrpc.client` is part of the Python standard library:
```python
import xmlrpc.client
```
Always create the proxy with `allow_none=True` so `null` values round-trip:
```python
proxy = xmlrpc.client.ServerProxy("{{ base }}/xmlrpc", allow_none=True)
```
## Endpoint
Point any XML-RPC client at:
```
{{ base }}/xmlrpc
```
The app reverse-proxies this path to the forking XML-RPC server (in production nginx forwards
`/xmlrpc` the same way). A plain `GET {{ base }}/xmlrpc` returns a short usage banner.
## Method names
Every endpoint becomes a method named after its API id with dots. A few you will use often:
| Method | REST route | Auth |
|--------|-----------|------|
| `feed.list` | `GET /feed` | public |
| `posts.detail` | `GET /posts/{post_slug}` | public |
| `posts.create` | `POST /posts/create` | user |
| `comments.create` | `POST /comments/create` | user |
| `votes.cast` | `POST /votes/{target_type}/{target_uid}` | user |
| `reactions.toggle` | `POST /reactions/{target_type}/{target_uid}` | user |
| `notifications.list` | `GET /notifications` | user |
| `notifications.mark.read` | `POST /notifications/mark-read/{notification_uid}` | user |
| `messages.send` | `POST /messages/send` | user |
| `follow.user` | `POST /follow/{username}` | user |
| `profile.detail` | `GET /profile/{username}` | public |
Discover the rest with introspection (below) - the list is generated from the same source as
this documentation, so it is always current.
## Calling convention
Each method takes **one struct** (a dictionary) of named parameters. The XML-RPC API routes each
key to the path, query string, or form body using the API reference, substitutes path
parameters such as `{username}`, and returns the same JSON payload the REST endpoint would,
decoded into a native Python value (`dict`, `list`, `str`, `int`, `float`, `bool`, or `None`).
```python
import xmlrpc.client
proxy = xmlrpc.client.ServerProxy("{{ base }}/xmlrpc", allow_none=True)
# A public read - no credentials needed
feed = proxy.feed.list({})
for item in feed["posts"]:
print(item["author"]["username"], "-", item["post"]["title"])
# An authenticated write - pass api_key inside the struct
result = proxy.posts.create({"content": "Hello from XML-RPC", "api_key": "{{ api_key }}"})
print(result["ok"], result["redirect"])
```
Pass an empty struct (`{}`) for methods that take no parameters. The dotted method name can
also be built dynamically with `getattr`, which is handy for generic wrappers:
```python
getattr(proxy, "notifications.list")({"api_key": "{{ api_key }}"})
```
## Authentication
Authenticate in any of three interchangeable ways. Public endpoints need none.
### 1. API key inside the struct
The simplest method - works with a vanilla `ServerProxy`, no custom transport:
```python
proxy.posts.create({"content": "posted with an API key", "api_key": "{{ api_key }}"})
```
Your API key is shown on your [profile page]({% if user %}/profile/{{ uname }}{% else %}/profile/YOUR_USERNAME{% endif %}); you can regenerate it there at any time.
### 2. API key as an HTTP header
Send `X-API-KEY` (or `Authorization: Bearer`) on the transport, exactly like the REST API.
Use this to keep credentials out of the call arguments. A small custom transport adds the
header to every request:
```python
import xmlrpc.client
class ApiKeyTransport(xmlrpc.client.SafeTransport): # SafeTransport = HTTPS
def __init__(self, api_key):
super().__init__()
self._api_key = api_key
def send_headers(self, connection, headers):
super().send_headers(connection, headers)
connection.putheader("X-API-KEY", self._api_key)
proxy = xmlrpc.client.ServerProxy(
"{{ base }}/xmlrpc", transport=ApiKeyTransport("{{ api_key }}"), allow_none=True
)
proxy.posts.create({"content": "posted with a header API key"})
```
Use `xmlrpc.client.Transport` instead of `SafeTransport` for a plain `http://` endpoint.
### 3. Username and password in the URL (HTTP Basic)
Put your **username (or email) and password** in the address. The XML-RPC API forwards the Basic
credentials to the normal login, so no API key is needed:
```python
proxy = xmlrpc.client.ServerProxy(
"{{ scheme }}://{{ uname }}:YOUR_PASSWORD@{{ host }}/xmlrpc", allow_none=True
)
proxy.posts.create({"content": "authenticated by username and password"})
```
> Always use `https://` in production so the API key or password is encrypted in transit.
## Return values
- **Reads** return the page payload as a struct - the same shape the JSON API returns. For
example `feed.list` returns `{"posts": [...], "next_cursor": "...", ...}`, where each post is
`{"post": {...}, "author": {...}, "comment_count": 3, ...}`.
- **Writes** return the action envelope `{"ok": true, "redirect": "/posts/...", "data": {...}}`.
`data` carries the created resource where applicable.
## Error handling
Any REST error surfaces as an `xmlrpc.client.Fault`. The `faultCode` carries the HTTP status
code and `faultString` carries the message:
| `faultCode` | Meaning |
|-------------|---------|
| `401` | Missing or invalid credentials |
| `403` | Authenticated but not allowed (for example a non-admin calling an admin method) |
| `404` | Target not found |
| `422` | Validation failed (a field is missing, too short, or invalid) |
| `5xx` | Server error |
| `-32001` | A required parameter was missing (raised before the call is made) |
```python
import xmlrpc.client
try:
proxy.posts.create({"content": "x"}) # no credentials
except xmlrpc.client.Fault as fault:
print("failed:", fault.faultCode, fault.faultString)
```
## Introspection
The XML-RPC API supports the full XML-RPC introspection set, so a client can discover and document
every method at runtime:
```python
proxy.system.listMethods() # every available method name
proxy.system.methodHelp("posts.create") # title, route, auth, and parameter list
proxy.system.methodSignature("posts.create")
```
`methodHelp` returns the endpoint summary, the HTTP method and path, the required role, and
every parameter with its location and type. Print the help for everything:
```python
proxy = xmlrpc.client.ServerProxy("{{ base }}/xmlrpc", allow_none=True)
for name in sorted(proxy.system.listMethods()):
print("###", name)
print(proxy.system.methodHelp(name))
print()
```
## Batching with multicall
`system.multicall` batches several calls into a single HTTP round trip:
```python
batch = xmlrpc.client.MultiCall(proxy)
batch.feed.list({})
batch.profile.detail({"username": "{{ uname }}"})
batch.notifications.counts({})
feed, profile, counts = tuple(batch())
```
## A reusable client wrapper
This small helper injects your credentials, supports all three auth styles, and lets you call
methods by dotted name. Drop it into any project:
```python
import xmlrpc.client
from urllib.parse import quote, urlsplit, urlunsplit
class DevPlace:
def __init__(self, base_url, api_key=None, username=None, password=None):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
endpoint = self.base_url + "/xmlrpc"
if username and password:
parts = urlsplit(self.base_url)
creds = f"{quote(username, safe='')}:{quote(password, safe='')}"
endpoint = urlunsplit((parts.scheme, f"{creds}@{parts.netloc}", "/xmlrpc", "", ""))
self.proxy = xmlrpc.client.ServerProxy(endpoint, allow_none=True)
def call(self, method, **params):
if self.api_key and "api_key" not in params:
params["api_key"] = self.api_key
return getattr(self.proxy, method)(params)
# usage
dp = DevPlace("{{ base }}", api_key="{{ api_key }}")
dp.call("posts.create", content="posted through the wrapper")
dp.call("feed.list")
```
## Pagination
List endpoints page with an opaque cursor. Pass the `next_cursor` from one page as `before` on
the next, until it comes back empty:
```python
def all_feed_posts(dp):
cursor = None
while True:
page = dp.call("feed.list", before=cursor) if cursor else dp.call("feed.list")
for item in page["posts"]:
yield item
cursor = page.get("next_cursor")
if not cursor:
return
```
The same `before`/`next_cursor` pattern works for `posts`, `gists`, `news`, `notifications.list`,
and bookmarks.
## Automation recipes
All of these assume the `DevPlace` wrapper above as `dp`.
```python
# Create a post (content is required; title and topic are optional)
post = dp.call("posts.create", content="Shipping the XML-RPC API today.", title="Release")
post_slug = post["data"]["slug"]
# Reply to a post with a comment (target_type defaults to "post")
detail = dp.call("posts.detail", post_slug=post_slug)
dp.call("comments.create", content="Nice work!", target_uid=detail["post"]["uid"])
# Upvote a post (value 1 to upvote, -1 to downvote)
dp.call("votes.cast", target_type="post", target_uid=detail["post"]["uid"], value="1")
# React with an emoji
dp.call("reactions.toggle", target_type="post", target_uid=detail["post"]["uid"], emoji="rocket")
# Follow a user
dp.call("follow.user", username="alice")
# Send a direct message (receiver_uid is the recipient's user uid)
dp.call("messages.send", content="Hi there", receiver_uid="USER_UID")
# Read your unread notifications
notifications = dp.call("notifications.list")
```
## Full example: a bot that replies to mentions
When someone writes `@yourname` in a post or comment, DevPlace creates a **mention**
notification whose `target_url` points at the post. This bot polls notifications, replies once
to each new mention, and marks it read so it is never processed twice. Save it as
`mention_bot.py`, set your credentials, and run it.
```python
#!/usr/bin/env python3
"""A DevPlace bot that replies to every @mention."""
import re
import time
import xmlrpc.client
URL = "{{ base }}"
API_KEY = "{{ api_key }}"
POLL_SECONDS = 30
REPLY = "Thanks for the mention! This is an automated reply via the XML-RPC API."
POST_SLUG = re.compile(r"/posts/([^/#?]+)")
class DevPlace:
def __init__(self, base_url, api_key):
self.api_key = api_key
self.proxy = xmlrpc.client.ServerProxy(base_url.rstrip("/") + "/xmlrpc", allow_none=True)
def call(self, method, **params):
params["api_key"] = self.api_key
return getattr(self.proxy, method)(params)
def unread_mentions(dp):
result = dp.call("notifications.list")
for group in result.get("notification_groups", []):
for entry in group.get("entries", []):
note = entry["notification"]
if note.get("type") == "mention" and not note.get("read"):
yield note
def reply_to_mention(dp, note):
match = POST_SLUG.search(note.get("target_url") or "")
if not match:
return
slug = match.group(1)
detail = dp.call("posts.detail", post_slug=slug)
dp.call("comments.create", content=REPLY, target_uid=detail["post"]["uid"], target_type="post")
def run():
dp = DevPlace(URL, API_KEY)
print("Mention bot started; polling every", POLL_SECONDS, "seconds.")
while True:
try:
for note in unread_mentions(dp):
try:
reply_to_mention(dp, note)
print("Replied to mention:", note.get("message"))
except xmlrpc.client.Fault as fault:
print("Could not reply:", fault.faultCode, fault.faultString)
finally:
dp.call("notifications.mark.read", notification_uid=note["uid"])
except xmlrpc.client.Fault as fault:
print("Poll error:", fault.faultCode, fault.faultString)
except OSError as error:
print("Network error, retrying:", error)
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
run()
```
How it works:
1. `notifications.list` returns notifications grouped for display; the bot flattens them and
keeps the unread ones of type `mention`.
2. The mention's `target_url` is the post link (for example `/posts/abc-my-post` or
`/posts/abc-my-post#comment-...`). A regex pulls the slug out of either form.
3. `posts.detail` resolves the slug to the post `uid`, and `comments.create` posts the reply.
4. `notifications.mark.read` is called in a `finally` block, so a mention is marked read even
if the reply failed - the bot never loops on the same item.
The bot is intentionally resilient: a `Fault` on one mention is logged and skipped, and network
errors are retried on the next poll.
### Running it continuously
- **Quick start:** `python mention_bot.py` (it loops forever).
- **cron** is not ideal for a long-running loop; for a scheduled one-shot pass, remove the
`while True` loop and run the body once, then schedule it with `cron`.
- **systemd** (recommended for a daemon): create a unit that runs `python mention_bot.py` with
`Restart=always`, so it survives reboots and crashes.
```ini
[Unit]
Description=DevPlace mention bot
After=network-online.target
[Service]
ExecStart=/usr/bin/python3 /opt/devplace-bot/mention_bot.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
## Direct messages
DevPlace has a full private-messaging API, which makes it ideal for interactive bots and chat
clients. Two methods do everything:
- `messages.inbox` (`GET /messages`) - with no parameters it returns your `conversations`
(each `{other_user, last_message, last_message_at, unread}`); with `with_uid` set to another
user's uid it returns that thread's `messages` (each `{message, sender, is_mine, time_ago}`)
**and marks those messages read**.
- `messages.send` (`POST /messages/send`) - send `content` to a `receiver_uid`.
Resolve a username to a uid with `profile.detail`:
```python
uid = dp.call("profile.detail", username="alice")["profile_user"]["uid"]
dp.call("messages.send", content="Hi Alice", receiver_uid=uid)
# read the conversation (this marks it read)
thread = dp.call("messages.inbox", with_uid=uid)
for item in thread["messages"]:
who = "me" if item["is_mine"] else item["sender"]["username"]
print(who, ":", item["message"]["content"])
```
## Full example: a chat bot over direct messages
This bot listens for incoming DMs and answers commands - a live, interactive assistant your
users reach by messaging it. It polls for **unread** conversations, opens each thread (which
marks it read), reads the latest incoming message, and replies. The `respond` function is the
only thing you change to give the bot a personality or new commands; here it serves `help`,
`feed`, `echo`, and `whoami`, pulling live data from DevPlace over the same XML-RPC API.
```python
#!/usr/bin/env python3
"""A DevPlace chat bot that answers direct messages."""
import os
import time
import xmlrpc.client
URL = os.environ.get("DEVPLACE_URL", "{{ base }}")
API_KEY = os.environ.get("DEVPLACE_API_KEY", "{{ api_key }}")
POLL_SECONDS = 10
class DevPlace:
def __init__(self, base_url, api_key):
self.api_key = api_key
self.proxy = xmlrpc.client.ServerProxy(base_url.rstrip("/") + "/xmlrpc", allow_none=True)
def call(self, method, **params):
params["api_key"] = self.api_key
return getattr(self.proxy, method)(params)
def respond(dp, text, sender):
text = (text or "").strip()
command = text.lower()
name = sender.get("username", "there")
if command in ("hi", "hello", "hey", "help", "/help"):
return f"Hi @{name}! Try: 'feed' for the latest posts, 'echo your message', or 'whoami'."
if command == "whoami":
return f"You are @{name} (uid {sender.get('uid')})."
if command == "feed":
posts = dp.call("feed.list").get("posts", [])[:3]
lines = [f"- {p['post'].get('title') or p['post']['uid']} by @{p['author']['username']}" for p in posts]
return "Latest posts:\n" + "\n".join(lines) if lines else "The feed is empty."
if command.startswith("echo "):
return text[5:]
return "I did not understand that. Send 'help' for commands."
def run():
dp = DevPlace(URL, API_KEY)
answered = set()
print("Chat bot started.")
while True:
try:
for conversation in dp.call("messages.inbox").get("conversations", []):
if not conversation.get("unread"):
continue
other = conversation.get("other_user") or {}
if not other.get("uid"):
continue
thread = dp.call("messages.inbox", with_uid=other["uid"]) # marks read
incoming = [m for m in thread.get("messages", []) if not m.get("is_mine")]
if not incoming:
continue
last = incoming[-1]
if last["message"]["uid"] in answered:
continue
answered.add(last["message"]["uid"])
reply = respond(dp, last["message"]["content"], other)
dp.call("messages.send", content=reply, receiver_uid=other["uid"])
print("Replied to @" + other.get("username", "?"))
except xmlrpc.client.Fault as fault:
print("error:", fault.faultCode, fault.faultString)
except OSError as error:
print("network error, retrying:", error)
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
run()
```
How it works:
1. `messages.inbox` (no args) lists conversations; the bot only opens ones flagged `unread`.
2. Opening a thread with `with_uid` returns the messages and **marks them read**, so the same
message is never answered twice (an extra `answered` set guards against re-polling within a
single run).
3. `is_mine` distinguishes the bot's own messages from the user's; the bot replies to the last
incoming one.
4. `respond` can call any other XML-RPC API method (here `feed.list`) to answer with live data, or
you can route the text to an LLM to make it a true AI assistant.
To run it forever, the same systemd unit shown for the mention bot works - just point
`ExecStart` at `chatbot.py`.
## Interactive terminal chat client
For a two-way chat from your terminal, run a background poll loop that prints incoming messages
while the main loop reads what you type and sends it. The full version is
`examples/xmlrpc/dm_client.py`; the core is:
```python
import threading, time, xmlrpc.client
dp = ... # the DevPlace wrapper
peer_uid = dp.call("profile.detail", username="alice")["profile_user"]["uid"]
def receive(stop, seen=set(), primed=[False]):
while not stop.is_set():
for item in dp.call("messages.inbox", with_uid=peer_uid).get("messages", []):
mid = item["message"]["uid"]
if mid in seen:
continue
seen.add(mid)
if primed[0] and not item["is_mine"]:
print(f"\n{item['sender']['username']}> {item['message']['content']}\nyou> ", end="")
primed[0] = True
time.sleep(3)
stop = threading.Event()
threading.Thread(target=receive, args=(stop,), daemon=True).start()
while True:
line = input("you> ")
if line.strip():
dp.call("messages.send", content=line, receiver_uid=peer_uid)
```
## More recipes
These build on the same `dp` wrapper. Each is shipped as a complete, runnable script in
`examples/xmlrpc/`.
### Deploy a local folder as a project
Create a project and push every text file into its virtual filesystem (`project.files.write`
creates parent directories automatically):
```python
from pathlib import Path
slug = dp.call("projects.create", title="My App", description="Deployed via XML-RPC",
project_type="software")["data"]["slug"]
root = Path("./my-app")
for path in root.rglob("*"):
if path.is_file():
dp.call("project.files.write", project_slug=slug,
path=path.relative_to(root).as_posix(), content=path.read_text())
```
### Back up your gists to disk
`gists.list` already includes each gist's `source_code`, so one paginated pass is enough:
```python
cursor = None
while True:
page = dp.call("gists.list", before=cursor) if cursor else dp.call("gists.list")
for item in page["gists"]:
gist = item["gist"]
open(gist["slug"] + ".txt", "w").write(gist.get("source_code") or "")
cursor = page.get("next_cursor")
if not cursor:
break
```
### Mirror an RSS feed into posts
Parse a feed with the standard library and create a post per new entry:
```python
import urllib.request, xml.etree.ElementTree as ET
root = ET.fromstring(urllib.request.urlopen("https://example.com/feed.xml").read())
for node in root.iter():
if node.tag.split("}")[-1] == "item":
title = node.findtext("title") or ""
link = node.findtext("link") or ""
dp.call("posts.create", content=f"{title}\n\n{link}", title=title[:120], topic="devlog")
```
### Build and post a digest
Combine several reads into one summary and publish it:
```python
posts = dp.call("feed.list")["posts"][:5]
leaders = dp.call("leaderboard")["entries"][:5]
news = dp.call("news.list")["articles"][:5]
digest = "# Digest\n" + "\n".join(f"- {p['post'].get('title')}" for p in posts)
dp.call("posts.create", content=digest, title="Daily digest", topic="devlog")
```
### Watch the feed live and auto-upvote
Poll the feed, print new posts, and upvote ones matching your keywords:
```python
import time
seen, keywords = set(), ["rust", "python"]
while True:
for item in dp.call("feed.list")["posts"]:
post = item["post"]
if post["uid"] in seen:
continue
seen.add(post["uid"])
text = f"{post.get('title') or ''} {post.get('content') or ''}".lower()
if any(word in text for word in keywords):
dp.call("votes.cast", target_type="post", target_uid=post["uid"], value="1")
time.sleep(20)
```
### Export all your data
```python
me = "alice"
export = {
"posts": dp.call("profile.detail", username=me, tab="posts")["posts"],
"gists": dp.call("profile.detail", username=me, tab="gists")["gists"],
"projects": dp.call("profile.detail", username=me, tab="projects")["projects"],
"bookmarks": dp.call("bookmarks.saved")["items"],
}
```
### An AI assistant in your DMs
Read direct messages over XML-RPC and answer with the DevPlace AI gateway (OpenAI-compatible,
authenticated with the same API key). The `ai_chatbot.py` example wires this into the DM loop;
the core call is:
```python
import json, urllib.request
def ask_ai(base_url, api_key, messages):
body = json.dumps({"model": "molodetz", "messages": messages}).encode()
request = urllib.request.Request(
f"{base_url}/openai/v1/chat/completions", data=body,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
)
with urllib.request.urlopen(request) as response:
return json.loads(response.read())["choices"][0]["message"]["content"]
```
## Best practices
- **Use HTTPS** so credentials are never sent in clear text.
- **Be polite to the rate limiter.** Mutating calls are rate limited per account; a 30-60s
poll interval and a single reply per event keep you well within limits. A burst of writes can
raise a `429` fault - back off and retry.
- **Mark notifications read** after handling them so you do not reprocess events.
- **Handle `Fault` per item**, not per batch, so one bad event does not stop the loop.
- **Keep replies useful**, and avoid `@`-mentioning people in automated replies unless you
intend to notify them, to prevent notification noise.
## Runnable examples
Complete, runnable versions of everything on this page live in the repository under
`examples/xmlrpc/`, all configured with environment variables and needing nothing beyond the
Python standard library:
- `client.py` - the reusable `DevPlace` wrapper (all three auth styles, dotted calls, introspection)
- `introspect.py` - prints every method with its help text
- `recipes.py` - posting, replying, voting, reacting, messaging, following, pagination
- `mention_bot.py` - replies to every `@mention`
- `chatbot.py` - command-driven direct-message bot
- `ai_chatbot.py` - AI-powered DM bot (gateway-backed replies)
- `dm_client.py` - two-way terminal chat
- `feed_watch.py` - live feed ticker with keyword auto-upvote
- `digest.py` - builds and posts a digest
- `gist_backup.py` - downloads all your gists
- `deploy_project.py` - pushes a local folder into a project
- `rss_to_posts.py` - mirrors an RSS feed into posts
- `export_data.py` - exports your account data to JSON