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
    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)
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:

  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.
[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:

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:

  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:

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 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