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.

community.example.com
SimpleReview extension
LatestTopCategories
feat Add OAuth provider for SSO with internal IdP AMK 12 428 2h
help How do I export all topics in a category? JP 5 112 4h
api API call fails with 429 — retry strategy? RT 8 203 23m
bug Webhook signature verification flaky on retries L 3 87 1d
Comment×
This API integration keeps hitting rate limits — fix the retry strategy?|
Fix it ✓ Done
waiting for selection…
Detected
Status429 Too Many
HeaderRetry-After:30
Fix plan
Wrap client in tenacity with exponential backoff · honour Retry-After · cap concurrency to 4
Result
Sustained 180 req/min without throttling. Backoff confirmed in logs.
✓ Fix ready
fix(discourse-api): add Retry-After backoff
discourse_client.py · +24 −3
SimpleReview drafts retry/backoff code for Discourse API integrations — straight to a site fix
Hitting 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

ThingValue
Base URLhttps://your-forum.example.com/ (any path; just append .json)
Schema referencehttps://docs.discourse.org/
Auth — server-to-serverApi-Key + Api-Username headers (admin)
Auth — end-user appUser-Api-Key + User-Api-Client headers
Anon rate limit60 req / 60s per IP
Authed rate limit200 req / 60s per user (defaults)
Create-action limit5–20 / minute per user (trust-level scaled)
429 includesRetry-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 on meta.discourse.org. You will need both.
  • Pick the right auth mode. Api-Key for backend automations, User-Api-Key for end-user apps. Mixing them up causes silent 403s.
  • Append .json to anything. Most "hidden" API endpoints are just the page URL with .json tacked on — no separate /api/v1/ namespace exists.
  • Rate limits bite hard. The defaults look generous until you parallelise; 429s come fast and the Retry-After header is the only honest signal.
  • 422 = validation. Always read response.errors from 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:

  1. docs.discourse.org — auto-generated OpenAPI/Swagger view of the most-used endpoints. Good for parameter shapes, weak on auth and errors.
  2. 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.
  3. 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.

PropertyApi-KeyUser-Api-Key
Created byAdmin → API → New API KeyEnd user via /user-api-key/new consent flow
HeadersApi-Key + Api-UsernameUser-Api-Key + User-Api-Client (optional)
Acts asAny user you specify (incl. system)Only the user who granted it
ScopesGlobal 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
RevocationAdmin → API pageUser profile → API keys
Best forBackend automations, ETLs, botsMobile 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
The 403 trap. An 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 varDefaultScope
DISCOURSE_MAX_REQS_PER_IP_PER_MINUTE200Per IP, all requests
DISCOURSE_MAX_REQS_PER_IP_PER_10_SECONDS50Burst window
DISCOURSE_MAX_USER_API_REQS_PER_MINUTE60Per User-Api-Key
DISCOURSE_MAX_USER_API_REQS_PER_DAY2880Per User-Api-Key per 24h
DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE60Per Api-Key
create-topic / create-post bucket~5–20 / minPer 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:

  1. 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.
  2. Crawling /latest.json?page=N without sleep. Pages are fast, the limit is not. Sleep at least 200 ms between page fetches and watch the response headers.
  3. 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 ![alt](upload://...) 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![repro]($SHORT)\",\"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![repro]({short})",
    "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")
Why this pattern. Discourse topics can be 5,000+ posts. Loading them via offset pagination would scan from the start every time. The stream array gives you a stable cursor of IDs that you can fetch in any order, in parallel-but-rate-limited.

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

StatusBody signatureMost 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
403Same as above, but request reached the controllerWrong 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:

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.

Install SimpleReview Chrome Extension →

Building a Discourse plugin instead? Read the plugin guide →

Frequently Asked Questions

Where is the official Discourse API documentation?
The schema reference lives at docs.discourse.org — it is a generated OpenAPI/Swagger view of the most-used endpoints. Higher-level guides (auth, webhooks, plugins) live on meta.discourse.org. The split is undocumented: docs.discourse.org has the JSON shapes, meta.discourse.org has the conceptual explanations and admin policies. Most integrations need both pages open at once.
What is the difference between an Api-Key and a User-Api-Key in Discourse?
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.
What are the Discourse API rate limits?
Defaults in 2026: 60 requests / 60 seconds per IP for anon, 200 / 60s per authenticated user for normal requests, plus separate buckets for create-actions (topics, posts, likes) tuned by trust level. Admins can raise globals via 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.
How do I create a Discourse API key?
Admin → API → New API Key. Pick a description, the user it acts as (often 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.
Can I use the Discourse API without admin access?
Yes — every authenticated user can request a User-Api-Key for their own account via /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.
Why do I get 403 Forbidden with a valid API key?
Three classic causes: (1) 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.
How do I paginate Discourse search results?
Add 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