Discourse API cookbook (2026): 10 working recipes — curl + Python
The official Discourse API documentation is split across two domains, three styles of authentication, and a rate-limit configuration that nobody mentions until you start hitting 429s in production. This cookbook is what we wish existed when we first integrated against a Discourse 3.4 instance: the auth split explained in one table, real env-var rate limits, and ten copy-paste recipes — every one tested against a live forum with curl and Python.
429 Too Many Requests on production? → SimpleReview reads your Discourse client code, spots missing Retry-After handling, and prepares a fix with tenacity-based exponential backoff in under a minute.
TL;DR — Discourse API at a glance
| Thing | Value |
|---|---|
| Base URL | https://your-forum.example.com/ (any path; just append .json) |
| Schema reference | https://docs.discourse.org/ |
| Auth — server-to-server | Api-Key + Api-Username headers (admin) |
| Auth — end-user app | User-Api-Key + User-Api-Client headers |
| Anon rate limit | 60 req / 60s per IP |
| Authed rate limit | 200 req / 60s per user (defaults) |
| Create-action limit | 5–20 / minute per user (trust-level scaled) |
| 429 includes | Retry-After: N header in seconds — honour it |
Key Takeaways
- The docs are split. Schema is at
docs.discourse.org, prose explanations live as forum threads onmeta.discourse.org. You will need both. - Pick the right auth mode.
Api-Keyfor backend automations,User-Api-Keyfor end-user apps. Mixing them up causes silent 403s. - Append
.jsonto anything. Most "hidden" API endpoints are just the page URL with.jsontacked on — no separate/api/v1/namespace exists. - Rate limits bite hard. The defaults look generous until you parallelise; 429s come fast and the
Retry-Afterheader is the only honest signal. - 422 = validation. Always read
response.errorsfrom the JSON body — Discourse returns very specific messages there.
Where the Discourse API actually lives
There is no single canonical Discourse API documentation page, and that is not your fault. Three sources matter:
- docs.discourse.org — auto-generated OpenAPI/Swagger view of the most-used endpoints. Good for parameter shapes, weak on auth and errors.
- meta.discourse.org — long-form forum posts that double as canonical docs. Search there for any "how do I…" question; the answers are pinned by Discourse staff.
- github.com/discourse/discourse_api — the official Ruby gem. Even if you do not write Ruby, its
lib/folder is the cleanest reference for which endpoints exist and what payloads they accept.
A pragmatic rule: if an endpoint is reachable from the web UI, you can call it as JSON by appending .json to the URL. /latest → /latest.json. /t/some-slug/123 → /t/123.json. /admin/dashboard → /admin/dashboard.json. This is the pattern, not an exception.
Authentication: Api-Key vs User-Api-Key
This is where most integrations fail on day one. The two key types look similar but behave differently and live in different admin screens.
| Property | Api-Key | User-Api-Key |
|---|---|---|
| Created by | Admin → API → New API Key | End user via /user-api-key/new consent flow |
| Headers | Api-Key + Api-Username | User-Api-Key + User-Api-Client (optional) |
| Acts as | Any user you specify (incl. system) | Only the user who granted it |
| Scopes | Global or granular (per action) | read, write, message_bus, push, notifications, session_info, one_time_password |
Can call /admin/* | Yes (if the user is admin) | No — even if granted by an admin |
| Revocation | Admin → API page | User profile → API keys |
| Best for | Backend automations, ETLs, bots | Mobile apps, browser extensions, end-user widgets |
Header examples for a request as the system user:
curl -H "Api-Key: 6e3f3...c2" \
-H "Api-Username: system" \
https://forum.example.com/latest.json
And as a regular user (Mary) holding her own User-Api-Key:
curl -H "User-Api-Key: af2e8...91" \
-H "User-Api-Client: my-mobile-app" \
https://forum.example.com/notifications.json
Api-Key without an Api-Username header silently 403s on most endpoints. Discourse does not tell you the username is missing — the response just looks like an auth failure. Always send both headers, always.
Rate limits in 2026 — what actually happens
Discourse uses a sliding-window rate limiter implemented with Redis, configured via env vars set at boot. If you never touch them you inherit these defaults (Discourse 3.4):
| Env var | Default | Scope |
|---|---|---|
DISCOURSE_MAX_REQS_PER_IP_PER_MINUTE | 200 | Per IP, all requests |
DISCOURSE_MAX_REQS_PER_IP_PER_10_SECONDS | 50 | Burst window |
DISCOURSE_MAX_USER_API_REQS_PER_MINUTE | 60 | Per User-Api-Key |
DISCOURSE_MAX_USER_API_REQS_PER_DAY | 2880 | Per User-Api-Key per 24h |
DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE | 60 | Per Api-Key |
| create-topic / create-post bucket | ~5–20 / min | Per user, scaled by trust level |
A 429 response looks like this:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{
"errors": ["You've performed this action too many times. Please wait 30 seconds before trying again."],
"error_type": "rate_limit",
"extras": { "wait_seconds": 30, "time_left": "30 seconds" }
}
Three traps that cause 90% of 429s in production:
- Parallel workers without coordination. Two pods both running a sync at minute zero will share one IP-level bucket — if you have a NAT in front, all your pods share it.
- Crawling
/latest.json?page=Nwithout sleep. Pages are fast, the limit is not. Sleep at least 200 ms between page fetches and watch the response headers. - Posting in a tight loop on a brand-new bot account. Trust level 0 has the strictest create-action bucket; promote your bot to TL2+ via admin, or stagger creations.
Honouring Retry-After is non-optional. The reusable Python helper we use:
import time, requests
from functools import wraps
def with_retry(fn):
@wraps(fn)
def inner(*args, **kw):
for attempt in range(5):
r = fn(*args, **kw)
if r.status_code != 429:
return r
wait = int(r.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait + 0.5)
r.raise_for_status()
return inner
@with_retry
def get(url, **kw):
return requests.get(url, **kw)
The 10 recipes
Every recipe below was run against a Discourse 3.4 instance with an admin-issued Api-Key stored in $DISCOURSE_API_KEY and the acting username in $DISCOURSE_API_USER. Set them once:
export DISCOURSE_HOST="https://forum.example.com"
export DISCOURSE_API_KEY="6e3f3....c2"
export DISCOURSE_API_USER="system"
Recipe 1 List the latest topics
The cheapest sanity check that your auth works. The endpoint is identical to what loads on the home page; it returns up to ~30 topics per page.
curl -s -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
"$DISCOURSE_HOST/latest.json?order=created&ascending=false" | jq '.topic_list.topics[0:3]'
import os, requests
H = {
"Api-Key": os.environ["DISCOURSE_API_KEY"],
"Api-Username": os.environ["DISCOURSE_API_USER"],
}
BASE = os.environ["DISCOURSE_HOST"]
r = requests.get(f"{BASE}/latest.json", headers=H, params={"order": "created", "ascending": "false"})
r.raise_for_status()
for t in r.json()["topic_list"]["topics"][:5]:
print(t["id"], t["fancy_title"], "·", t["posts_count"], "posts")
Recipe 2 Create a topic in a category
POST to /posts.json — yes, posts, not topics. Discourse creates the topic implicitly when you supply a title. Required fields: title, raw, category (numeric ID, not slug).
curl -s -X POST -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
-H "Content-Type: application/json" \
-d '{
"title": "Release notes for v3.4",
"raw": "We just shipped 3.4 — full changelog inside.",
"category": 7,
"tags": ["release", "v3-4"]
}' \
"$DISCOURSE_HOST/posts.json"
payload = {
"title": "Release notes for v3.4",
"raw": "We just shipped 3.4 — full changelog inside.",
"category": 7,
"tags": ["release", "v3-4"],
}
r = requests.post(f"{BASE}/posts.json", headers={**H, "Content-Type": "application/json"}, json=payload)
if r.status_code == 422:
print("validation:", r.json().get("errors"))
r.raise_for_status()
topic = r.json()
print("created topic", topic["topic_id"], "post", topic["id"])
Common 422s: title shorter than min_topic_title_length (default 15), raw shorter than min_post_length (default 20), or category locked for the acting user.
Recipe 3 Update a post (admin)
PUT /posts/:id.json. Pass an edit_reason — Discourse logs it in the staff action log and shows it on the post.
curl -s -X PUT -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
-H "Content-Type: application/json" \
-d '{
"post": {
"raw": "Updated body — added migration steps.",
"edit_reason": "Fix migration command typo"
}
}' \
"$DISCOURSE_HOST/posts/4837.json"
post_id = 4837
body = {"post": {"raw": "Updated body — added migration steps.",
"edit_reason": "Fix migration command typo"}}
r = requests.put(f"{BASE}/posts/{post_id}.json",
headers={**H, "Content-Type": "application/json"},
json=body)
r.raise_for_status()
print("revision", r.json()["post"]["version"])
Note: edits trigger the rebake pipeline. Expect the response in <200 ms but downstream webhooks (post_edited) may fire seconds later.
Recipe 4 Upload an image, then attach it to a post
Two-step: upload to /uploads.json, get back a short_url (looks like upload://abc123.png), then embed it in the post body using the standard markdown  syntax.
UPLOAD=$(curl -s -X POST -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
-F "type=composer" \
-F "synchronous=true" \
-F "file=@./screenshot.png" \
"$DISCOURSE_HOST/uploads.json")
SHORT=$(echo "$UPLOAD" | jq -r '.short_url')
echo "Got: $SHORT"
curl -s -X POST -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
-H "Content-Type: application/json" \
-d "{\"title\":\"Bug repro\",\"raw\":\"Here is the screenshot:\n\n\",\"category\":4}" \
"$DISCOURSE_HOST/posts.json"
import requests
with open("screenshot.png", "rb") as f:
up = requests.post(
f"{BASE}/uploads.json",
headers=H,
files={"file": ("screenshot.png", f, "image/png")},
data={"type": "composer", "synchronous": "true"},
).json()
short = up["short_url"] # 'upload://abc123def456.png'
body = {
"title": "Bug repro",
"raw": f"Here is the screenshot:\n\n",
"category": 4,
}
r = requests.post(f"{BASE}/posts.json", headers={**H, "Content-Type": "application/json"}, json=body)
r.raise_for_status()
The type=composer hint matters — without it, the upload is filed as a profile/avatar and is not embeddable in posts. synchronous=true blocks until the file is processed; for big uploads (>5 MB) drop it and poll /uploads/lookup-urls.json.
Recipe 5 Send a private message
Same /posts.json endpoint, with archetype: "private_message" and a target_recipients field (comma-separated usernames or group names).
curl -s -X POST -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
-H "Content-Type: application/json" \
-d '{
"title": "Action required: confirm your email",
"raw": "Hi — please click the verify link we sent yesterday.",
"archetype": "private_message",
"target_recipients": "mary,bob,@admins"
}' \
"$DISCOURSE_HOST/posts.json"
dm = {
"title": "Action required: confirm your email",
"raw": "Hi — please click the verify link we sent yesterday.",
"archetype": "private_message",
"target_recipients": "mary,bob,@admins",
}
r = requests.post(f"{BASE}/posts.json",
headers={**H, "Content-Type": "application/json"},
json=dm)
r.raise_for_status()
To message a group, prefix with @: "@admins". Discourse silently drops invalid recipients — check response["topic"]["allowed_users"] matches what you sent.
Recipe 6 Create a user (admin)
Admin-only: POST /users.json. Password rules: at least 10 chars (default min_password_length), and must not match the username. Set active=true + approved=true to skip email verification — useful for SSO bridges.
curl -s -X POST -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
-H "Content-Type: application/json" \
-d '{
"name": "Mary Example",
"username": "mary_e",
"email": "[email protected]",
"password": "Initial-PW-2026!",
"active": true,
"approved": true
}' \
"$DISCOURSE_HOST/users.json"
payload = {
"name": "Mary Example",
"username": "mary_e",
"email": "[email protected]",
"password": "Initial-PW-2026!",
"active": True,
"approved": True,
}
r = requests.post(f"{BASE}/users.json",
headers={**H, "Content-Type": "application/json"},
json=payload)
data = r.json()
if not data.get("success"):
# account_create errors land in `errors` or `message`
print("create failed:", data.get("errors") or data.get("message"))
else:
print("user_id", data["user_id"])
Watch out: a 200 response with "success": false is the common failure mode (duplicate email, weak password, banned IP). Always check the body, not just the status code.
Recipe 7 Search topics with pagination
/search.json?q=...&page=N. The q string supports the same operators as the on-site search: category:bug, tags:release, after:2026-01-01, user:mary, etc.
curl -s -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
"$DISCOURSE_HOST/search.json?q=429+rate+limit+category:api&page=1" | jq '.posts | length'
def search_all(q):
out = []
page = 1
while True:
r = requests.get(f"{BASE}/search.json", headers=H,
params={"q": q, "page": page})
r.raise_for_status()
posts = r.json().get("posts", [])
if not posts:
return out
out.extend(posts)
page += 1
time.sleep(0.3) # polite
hits = search_all("429 rate limit category:api")
print(len(hits), "matching posts")
Empty posts array = end of results. Discourse caps search at 50 pages (~500 results); for bigger crawls combine with /latest.json + filter client-side.
Recipe 8 Pull every post in a topic (cursor pagination)
The first /t/:id.json response contains a post_stream.stream array — every post ID in the topic, in order. Big topics ship only the first ~20 posts inline; the rest you batch-fetch via /t/:id/posts.json?post_ids[]=..., up to 20 IDs per call.
TOPIC=2837
STREAM=$(curl -s -H "Api-Key: $DISCOURSE_API_KEY" -H "Api-Username: $DISCOURSE_API_USER" \
"$DISCOURSE_HOST/t/$TOPIC.json" | jq -r '.post_stream.stream | join(",")')
echo "Topic has $(echo "$STREAM" | tr ',' '\n' | wc -l) posts"
def fetch_topic_posts(topic_id):
envelope = requests.get(f"{BASE}/t/{topic_id}.json", headers=H).json()
stream = envelope["post_stream"]["stream"]
inline = {p["id"]: p for p in envelope["post_stream"]["posts"]}
missing = [pid for pid in stream if pid not in inline]
for chunk_start in range(0, len(missing), 20):
chunk = missing[chunk_start:chunk_start + 20]
params = [("post_ids[]", str(pid)) for pid in chunk]
r = requests.get(f"{BASE}/t/{topic_id}/posts.json",
headers=H, params=params)
for p in r.json()["post_stream"]["posts"]:
inline[p["id"]] = p
time.sleep(0.2)
return [inline[pid] for pid in stream]
posts = fetch_topic_posts(2837)
print(f"fetched {len(posts)} posts")
Recipe 9 Bulk-tag topics via webhook + API (Node)
A common automation: when a topic is posted in #support, tag it with the user's plan tier from your billing system. The flow is webhook in → look up plan → API call out.
# Discourse → Admin → API → Webhooks → New Webhook
# URL: https://automation.example.com/discourse/hook
# Events: Topic Event (topic_created)
# Secret: choose a long random string, copy it
// receiver.js — verifies the signature, then PUTs new tags back
const express = require("express");
const crypto = require("crypto");
const fetch = require("node-fetch");
const SECRET = process.env.WEBHOOK_SECRET;
const FORUM = process.env.DISCOURSE_HOST;
const KEY = process.env.DISCOURSE_API_KEY;
const USER = process.env.DISCOURSE_API_USER;
const app = express();
app.use(express.raw({ type: "application/json" }));
app.post("/discourse/hook", async (req, res) => {
const sig = req.header("X-Discourse-Event-Signature") || "";
const expected = "sha256=" + crypto.createHmac("sha256", SECRET)
.update(req.body)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).send("bad signature");
}
const evt = req.header("X-Discourse-Event");
if (evt !== "topic_created") return res.sendStatus(204);
const { topic } = JSON.parse(req.body.toString());
const plan = await lookupPlanTier(topic.created_by.username); // your billing API
await fetch(`${FORUM}/t/-/${topic.id}.json`, {
method: "PUT",
headers: {
"Api-Key": KEY,
"Api-Username": USER,
"Content-Type": "application/json",
},
body: JSON.stringify({ tags: [...(topic.tags || []), `plan-${plan}`] }),
});
res.sendStatus(200);
});
app.listen(8080);
Notes: the signature header is X-Discourse-Event-Signature (sha256 HMAC of the raw body); always use timingSafeEqual; the event type lives in X-Discourse-Event. Reply 2xx within 15 s or Discourse will retry — and rate-limit your retries.
Recipe 10 Read site-wide stats (admin dashboard)
The dashboard you see at /admin/dashboard is fed by /admin/dashboard.json and individual report endpoints. Useful for embedding forum metrics into your own internal dashboards.
curl -s -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
"$DISCOURSE_HOST/admin/dashboard.json" | jq '{users:.global_reports[0].total, topics:.global_reports[1].total}'
# Or a specific report (topics created in last 30 days):
curl -s -H "Api-Key: $DISCOURSE_API_KEY" \
-H "Api-Username: $DISCOURSE_API_USER" \
"$DISCOURSE_HOST/admin/reports/topics.json?start_date=2026-04-01&end_date=2026-04-30"
from datetime import date, timedelta
end = date.today()
start = end - timedelta(days=30)
r = requests.get(f"{BASE}/admin/reports/topics.json",
headers=H,
params={"start_date": start.isoformat(),
"end_date": end.isoformat()})
report = r.json()["report"]
total = sum(d["y"] for d in report["data"])
print(f"{report['title']}: {total} in last 30 days")
Available reports include signups, topics, posts, likes, flags, visits, page_view_logged_in_reqs, dau_by_mau, and many more — look at the global_reports array in /admin/dashboard.json for the full list on your version.
Common 4xx traps — table
| Status | Body signature | Most likely cause |
|---|---|---|
401 | {"errors":["You are not permitted to view the requested resource."]} | Missing or wrong Api-Key; for User-Api-Key, missing User-Api-Client |
403 | Same as above, but request reached the controller | Wrong Api-Username (e.g. acting as a regular user against /admin/*); granular key without the right scope |
404 | {"errors":["The requested URL or resource could not be found."]} | Topic/post deleted, ID typo, or trailing slash mismatch — Discourse is strict about .json |
422 | {"errors":["Title is too short", "Body must be at least 20 chars"]} | Validation — read the strings, they are user-grade |
429 | {"error_type":"rate_limit","extras":{"wait_seconds":30}} | Sleep for Retry-After; check parallelism and trust level |
409 | {"errors":["Post must be unique"]} | Discourse blocks identical posts within a window — vary the body |
What to skip in the official docs
Two things will save you time:
- Ignore the "API v1" prefix. It does not exist. The Swagger schema labels it that way for OpenAPI compatibility, but no endpoint is reachable at
/api/v1/...on a real instance. - Do not trust the rate-limit table on docs.discourse.org. It lags real defaults. Always check
app.ymlor/srv/discourse/config/site_settings.ymlon your instance, or grep the running container forDISCOURSE_MAX_env vars.
Ship Discourse integrations without the 429 surprises
Paste your client code into SimpleReview — it spots missing Retry-After handling, missing Api-Username headers, and unsafe webhook signature checks, then prepares a site fix.
Building a Discourse plugin instead? Read the plugin guide →
Frequently Asked Questions
Api-Key is an admin-issued global key — you send Api-Key + Api-Username headers and act as that user. It is meant for trusted server-to-server integrations. User-Api-Key is granted by an individual user via an OAuth-like consent flow (originally for the official mobile app); it scopes to that user only, and supports limited scopes like read, write, push, notifications. Use Api-Key for backend automations, User-Api-Key for end-user-facing apps.DISCOURSE_MAX_REQS_PER_IP_PER_MINUTE and DISCOURSE_MAX_USER_API_REQS_PER_MINUTE. A 429 response includes a Retry-After header in seconds — honour it.system), and a scope (Global / Granular). Granular keys can be limited to specific actions like topics:write — strongly preferred for production. Save the key somewhere safe; Discourse will not show it again./user-api-key/new and use it to read and post as themselves. You cannot create users, edit other users' posts, see flagged content, or call /admin/* endpoints without an admin Api-Key.Api-Username header is missing or wrong — Api-Key alone is not enough. (2) The user you are acting as does not have permission for the endpoint (e.g. acting as a regular user against /admin/*). (3) The key is granular and the action you are calling is not in its allow list. Check Logs → Staff Action Log on Discourse for the rejection reason.page=N to /search.json — pages are 1-indexed and capped at 10 results per page by default (configurable via search_page_size). For full-topic crawls prefer /latest.json?page=N which yields ~30 topics per page. For posts inside a topic use /t/{id}/posts.json with stream IDs from the /t/{id}.json envelope, not page numbers.Related Discourse guides
Sources
- docs.discourse.org — OpenAPI schema reference
- meta.discourse.org — Discourse API documentation thread (#22706)
- meta.discourse.org — Global rate limits and throttling (#78612)
- meta.discourse.org — Admin Api-Keys and User-Api-Keys (#132372)
- github.com/discourse/discourse_api — official Ruby gem
- discourse/config/site_settings.yml — live defaults