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