XML-RPC API
DevPlace exposes its entire REST API over XML-RPC at https://devplace.net/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
and Conventions & Errors.
Requirements
Nothing to install. xmlrpc.client is part of the Python standard library:
import xmlrpc.client
Always create the proxy with allow_none=True so null values round-trip:
proxy = xmlrpc.client.ServerProxy("https://devplace.net/xmlrpc", allow_none=True)
Endpoint
Point any XML-RPC client at:
https://devplace.net/xmlrpc
The app reverse-proxies this path to the forking XML-RPC server (in production nginx forwards
/xmlrpc the same way). A plain GET https://devplace.net/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).
import xmlrpc.client
proxy = xmlrpc.client.ServerProxy("https://devplace.net/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": "YOUR_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:
getattr(proxy, "notifications.list")({"api_key": "YOUR_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:
proxy.posts.create({"content": "posted with an API key", "api_key": "YOUR_API_KEY"})
Your API key is shown on your profile page; 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:
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(
"https://devplace.net/xmlrpc", transport=ApiKeyTransport("YOUR_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:
proxy = xmlrpc.client.ServerProxy(
"https://YOUR_USERNAME:YOUR_PASSWORD@devplace.net/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
examplefeed.listreturns{"posts": [...], "next_cursor": "...", ...}, where each post is
{"post": {...}, "author": {...}, "comment_count": 3, ...}. - Writes return the action envelope
{"ok": true, "redirect": "/posts/...", "data": {...}}.
datacarries 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) |
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:
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:
proxy = xmlrpc.client.ServerProxy("https://devplace.net/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:
batch = xmlrpc.client.MultiCall(proxy)
batch.feed.list({})
batch.profile.detail({"username": "YOUR_USERNAME"})
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:
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("https://devplace.net", api_key="YOUR_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:
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.
# 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.
#!/usr/bin/env python3
"""A DevPlace bot that replies to every @mention."""
import re
import time
import xmlrpc.client
URL = "https://devplace.net"
API_KEY = "YOUR_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:
notifications.listreturns notifications grouped for display; the bot flattens them and
keeps the unread ones of typemention.- The mention's
target_urlis the post link (for example/posts/abc-my-postor
/posts/abc-my-post#comment-...). A regex pulls the slug out of either form. posts.detailresolves the slug to the postuid, andcomments.createposts the reply.notifications.mark.readis called in afinallyblock, 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 Trueloop and run the body once, then schedule it withcron. - systemd (recommended for a daemon): create a unit that runs
python mention_bot.pywith
Restart=always, so it survives reboots and crashes.
[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 yourconversations
(each{other_user, last_message, last_message_at, unread}); withwith_uidset to another
user's uid it returns that thread'smessages(each{message, sender, is_mine, time_ago})
and marks those messages read.messages.send(POST /messages/send) - sendcontentto areceiver_uid.
Resolve a username to a uid with profile.detail:
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.
#!/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", "https://devplace.net")
API_KEY = os.environ.get("DEVPLACE_API_KEY", "YOUR_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:
messages.inbox(no args) lists conversations; the bot only opens ones flaggedunread.- Opening a thread with
with_uidreturns the messages and marks them read, so the same
message is never answered twice (an extraansweredset guards against re-polling within a
single run). is_minedistinguishes the bot's own messages from the user's; the bot replies to the last
incoming one.respondcan call any other XML-RPC API method (herefeed.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:
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):
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:
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:
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:
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:
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
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:
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 a429fault - back off and retry. - Mark notifications read after handling them so you do not reprocess events.
- Handle
Faultper 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 reusableDevPlacewrapper (all three auth styles, dotted calls, introspection)introspect.py- prints every method with its help textrecipes.py- posting, replying, voting, reacting, messaging, following, paginationmention_bot.py- replies to every@mentionchatbot.py- command-driven direct-message botai_chatbot.py- AI-powered DM bot (gateway-backed replies)dm_client.py- two-way terminal chatfeed_watch.py- live feed ticker with keyword auto-upvotedigest.py- builds and posts a digestgist_backup.py- downloads all your gistsdeploy_project.py- pushes a local folder into a projectrss_to_posts.py- mirrors an RSS feed into postsexport_data.py- exports your account data to JSON