Reddit (reddit)
Reddit is a community platform where every post targets one subreddit. The Rustiz provider publishes one subreddit per async step, uploads images and MP4 videos to Reddit’s S3-backed media endpoint, and can attach a required link flair. Comments are published as top-level replies on the submission.
- Rust source:
crates/rustiz-providers/src/reddit.rs - Upstream port:
reddit.provider.ts - Editor: Normal
- Connection mode:
oauth
2026 status — read this before creating an app. Reddit ended self-service Data API access in November 2025. New OAuth apps need explicit approval under the Responsible Builder Policy, and in 2026 Reddit announced that public Data API apps are being migrated to the Developer Platform (Devvit). Apps that have not registered or engaged start losing API access on 12 January 2027, and remaining public API access ends in March 2027. Existing approved apps still work for now; new public API access is effectively closed. Verify the current state in your Reddit account before building a production rollout.
Overview
| Property | Value |
|---|---|
| Identifier | reddit |
| Display name | |
| Connection mode | oauth (a human opens a Reddit consent page) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; extra posts are flat top-level comments on the submission, not nested reply chains |
| Pending / async publishing | Yes. supports_pending = true; post_pending records the subreddit list, finalize_post publishes exactly one subreddit per call |
| Analytics | No. supports_analytics = false |
| Post analytics | No. supports_post_analytics = false |
| Mentions | No. supports_mentions = false |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 1 — Reddit’s write limits are strict and shared per OAuth client |
Character limit (max_length) | 10000 characters, hard-coded |
| OAuth scopes | read, identity, submit, flair |
| Redirect URI | {FRONTEND_URL}/integrations/social/reddit |
| API host | https://oauth.reddit.com (override REDDIT_BASE_URL) |
| OAuth / token host | https://www.reddit.com (override REDDIT_WEB_BASE_URL) |
Server Environment Variables
required_env() returns exactly:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"]
}
}
required_env_alternatives() is not overridden.
| Variable | Required | Default | Purpose |
|---|---|---|---|
REDDIT_CLIENT_ID | Yes | — | Client id shown under the app name at reddit.com/prefs/apps |
REDDIT_CLIENT_SECRET | Yes | — | App secret shown on the app card |
REDDIT_BASE_URL | No | https://oauth.reddit.com | API origin for /api/v1/me, /api/submit, subreddit lookups, media assets, comments. A trailing slash is stripped. |
REDDIT_WEB_BASE_URL | No | https://www.reddit.com | Origin used for /api/v1/authorize and /api/v1/access_token, and to build release URLs. A trailing slash is stripped. |
FRONTEND_URL | Yes in practice | http://localhost:5000 | Base of the OAuth redirect URI. Trailing slashes are stripped. |
JWT_SECRET | Yes in practice | empty | Encrypts the pending OAuth state row. |
rustiz providers --status only checks that REDDIT_CLIENT_ID and
REDDIT_CLIENT_SECRET are non-empty. It cannot tell whether the app was
approved for the Data API, whether the redirect URI matches, or whether the
credentials belong to the intended Reddit account.
The two base-URL overrides are independent: setting only REDDIT_BASE_URL
does not redirect the OAuth authorize or token calls. For a mock server,
set both.
Step-by-Step Developer App Setup
1. Create the Reddit app
- Sign in to Reddit with the account that will own the app.
- Open https://www.reddit.com/prefs/apps (or
https://old.reddit.com/prefs/apps). - Scroll to Developed Applications and click create another app / are you a developer? create an app.
- Fill in:
- name — e.g.
RustizorMyRustizInstance. - type — web app. This is the confidential type that has a client
secret and supports the authorization-code flow. Do not use
installed app(no secret) orscript(only the owner’s account). - description — anything descriptive.
- about url — your deployment URL (optional but recommended).
- redirect uri — the exact Rustiz callback from the next section.
- name — e.g.
- Click create app and copy:
- the client id shown immediately under the app name;
- the secret shown on the app card.
Reddit’s app page and the approval process are separate. Creating the app does not grant Data API access by itself. If Reddit’s portal refuses to create a new app or the approval request is closed for your use case, this provider cannot be used; there is no code-side workaround.
2. Set the environment and restart
REDDIT_CLIENT_ID="…"
REDDIT_CLIENT_SECRET="…"
FRONTEND_URL="https://social.example.com"
Restart every process that reads configuration (rustiz serve, rustiz worker, rustiz mcp), then check:
rustiz providers --status
3. Redirect URI (exact)
Reddit::redirect_uri() is:
#![allow(unused)]
fn main() {
format!(
"{}/integrations/social/reddit",
self.ctx.config.frontend_url()
)
}
Config::frontend_url() trims trailing slashes, so the registered value must be
without a trailing slash:
https://social.example.com/integrations/social/reddit
Local examples:
http://localhost:5000/integrations/social/reddit # Docker / rustiz serve default
http://localhost:4200/integrations/social/reddit # upstream Postiz dev UI
There is no redirectmeto.com wrapper for Reddit. Reddit requires the
redirect_uri to match the registered URI exactly at both the authorize step
and the token exchange; a mismatch produces a 403 on the consent page or
invalid_grant at token exchange.
4. Scopes requested and checked
Reddit::scopes() returns:
#![allow(unused)]
fn main() {
vec!["read", "identity", "submit", "flair"]
}
The authorize URL joins them with %20:
https://www.reddit.com/api/v1/authorize?client_id=…
&response_type=code
&state=…
&redirect_uri=…
&duration=permanent
&scope=read%20identity%20submit%20flair
| Scope | Why Rustiz needs it |
|---|---|
read | /subreddits/search, /r/{name}/about, /user/{name}/submitted, post requirements and flair listings |
identity | GET /api/v1/me during connect (id, name, avatar) |
submit | POST /api/submit and POST /api/comment |
flair | Reading link_flair_v2 and sending flair_id |
authenticate() calls check_scopes() on the token response’s scope string.
The helper accepts comma- or space-separated scopes. If the user unchecks any
of the four scopes, the connection fails with NotEnoughScopes rather than
silently connecting a partially working channel.
duration=permanent is always requested, so Reddit returns a refresh token.
Access tokens expire after one hour; the refresh token does not.
5. Sandbox / testing vs production approval
Reddit has no sandbox app type or test-mode client. There is one app and one approval state.
- Request Data API access first. Submit Reddit’s developer request form (the link is on the Responsible Builder Policy page) and describe the app, the subreddits it will touch, the actions it needs, and how you handle deleted content. Reddit’s review is human and typically takes weeks; it can be refused.
- Test with a test account and a subreddit you moderate. Create a private
or low-traffic test subreddit (for example
r/u_<yourname>or a privater/rustiz_test) so a bad post cannot hit a real community. Reddit’s site-wide rules and each subreddit’s own rules still apply. - Expect account-age and karma gates. Many subreddits reject posts from new or low-karma accounts even when the API call succeeds. That is subreddit moderation, not an OAuth failure.
- A successful OAuth consent is not API approval. Without approved API
access, the consent page may work while
oauth.reddit.comreturns401,403or throttled traffic. - Production uses the same client id/secret, redirect URI and scopes. There is no per-environment app review. Commercial use needs a separate written agreement with Reddit.
- Register the app on the Developer Platform. Reddit’s migration guidance asks existing apps to register at https://developers.reddit.com/app-registration and engage with the migration team. Apps that do not register or respond start losing API access on 12 January 2027.
2026 migration: Reddit’s public Data API is being replaced by apps that run on the Developer Platform (Devvit). Reddit’s September 2026 migration post says unregistered apps start losing access on 12 January 2027 and remaining public API access ends in March 2027. Plan a Devvit migration or a contractual arrangement if Reddit is business-critical.
Connecting a Channel (CLI & MCP)
CLI
rustiz channels connect reddit --open
- The login URL is printed to stderr;
--openlaunches the default browser. - With a local
FRONTEND_URL(http://localhost:…/http://127.0.0.1:…) and no separate server, the CLI serves the callback itself on the frontend port and waits up to--timeoutseconds (default300) for the channel to appear. - With a public HTTPS
FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll withrustiz channels connect-status <connectionId>orrustiz channels list. - On success the JSON document contains
connected: trueand the new channel’sid,identifier,name,profile,pictureandrefreshNeededfields.
MCP connectChannelTool
Start:
{ "provider": "reddit" }
The tool returns:
{
"connected": false,
"pending": true,
"requiresHuman": true,
"connectionId": "…",
"url": "https://www.reddit.com/api/v1/authorize?...",
"message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}
Give the url to a human, then poll:
{ "connectionId": "…" }
provider is not needed when polling. Polling is safe to repeat; the pending
state lives in oauth_states for one hour and is scoped to the organization.
Follow-up functions after connecting
Reddit has no customFields, no externalUrl, and is_between_steps = false,
so there is no channelSetupTool step. The subreddit list and flair are chosen
when the post is created, not when the channel is connected.
Two read-only @Tool functions are exposed through triggerTool:
{
"integrationId": "<reddit channel id>",
"methodName": "subreddits",
"dataSchema": [{ "key": "word", "value": "rust" }]
}
{
"integrationId": "<reddit channel id>",
"methodName": "restrictions",
"dataSchema": [{ "key": "subreddit", "value": "/r/rust" }]
}
subreddits accepts a search word or a path such as /r/rust; it returns
{title, name, id} entries for public subreddits, excluding image-only
subreddits. restrictions returns:
{
"subreddit": "/r/rust",
"allow": ["self", "link", "media"],
"is_flair_required": true,
"flairs": [{ "id": "…", "name": "Discussion" }]
}
Use those values to build settings.subreddit[*].value; they are not stored on
the integration.
What not to send
customFields— the genericconnectpath would base64-encode the object and pass it as the OAuthcode, which Reddit will reject. Reddit is OAuth only.externalUrl— Reddit is not self-hosted. The generic path calls the provider’sexternal_url(), which returnsUnsupported("externalUrl").
Post Settings & Media Validation
settings_schema()
{
"type": "object",
"properties": {
"subreddit": {
"type": "array",
"minItems": 1,
"description": "One entry per subreddit the post is published to.",
"items": {
"type": "object",
"properties": {
"value": {
"type": "object",
"properties": {
"subreddit": {
"type": "string",
"minLength": 2,
"description": "Subreddit must start with /r"
},
"title": {
"type": "string",
"minLength": 2,
"description": "Title of the Reddit submission."
},
"type": {
"type": "string",
"enum": ["self", "link", "media"],
"description": "self = text post, link = URL post, media = first attached image or MP4 video"
},
"url": {
"type": "string",
"description": "Destination URL when type is link."
},
"is_flair_required": { "type": "boolean" },
"flair": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
},
"required": ["id", "name"]
}
},
"required": ["subreddit", "title", "type"]
}
},
"required": ["value"]
}
}
},
"required": ["subreddit"],
"additionalProperties": true
}
Each array entry is one subreddit. The same text and the first media item are reused for every entry; there is no per-subreddit body/title override.
validate_settings() rules
subredditmust be an array with at least one entry.- Every entry must have a
valueobject. value.subredditmust be at least 2 characters. The error says “Subreddit must start with /r”, but the code only checks length.rustand/r/rustboth pass.value.titlemust be at least 2 characters.value.typemust be one ofself,link,media.- For
type: "link", ifurlis present it must parse ashttp/https, or contain the literal(post:. A missingurlkey is not rejected (the check is skipped); an empty string is rejected asInvalid URL. - If
is_flair_requiredis truthy,flair.idandflair.namemust both be non-empty. - If
is_flair_requiredis false, an attachedflairis still sent if present.
check_validity() rules
Two pre-flight errors are returned as strings:
- If any subreddit entry has
type: "media"and the first post group does not contain exactly one media item:When posting a media post, you must attached exactly one media file.The check usesposts.first()only, so it is really “exactly one media in the main post”, not per subreddit. - If any post in any group has an
.mp4path without a thumbnail:You must attach a thumbnail to your video post.The check is case-sensitive (mp4), while the upload path lowercases the extension.
Media requirements
| Type | Accepted by the provider | Notes |
|---|---|---|
type: "self" | No media needed | Text post; media attached to the engine post is ignored |
type: "link" | No media needed | url is sent as the link destination |
type: "media", image | Exactly 1 image | Uploaded to Reddit’s S3 asset endpoint, then submitted as kind=image |
type: "media", video | Exactly 1 .mp4 | Submitted as kind=video; a thumbnail is mandatory and uploaded as video_poster_url |
The upload flow is:
POST {REDDIT_BASE_URL}/api/media/assetwith multipart fieldsfilepathandmimetype.- Reddit returns
args.action(often protocol-relative,//…) andargs.fields. - Rustiz re-uploads the file as multipart to the S3 action and reads the
<Location>element from the XML response. - The returned URL is sent as
urlin/api/submit.
MIME type is chosen from the path extension; a query string makes the extension
unknown, so the part falls back to application/octet-stream. Reddit’s own size
limits still apply (commonly 20 MB for images and 1 GB for video); Rustiz does
not pre-check size.
Non-MP4 video (.mov, .webm) is treated as an image because the only
video test is path.ends_with(".mp4") during finalize_post (has_extension
is case-insensitive there). Use MP4 for native video.
Threads and comments
supports_comments = true. The first entry is the submission; each later entry
is published with POST /api/comment and thing_id = t3_<root post id>.
last_comment_id is accepted but ignored. Every comment is a top-level
reply on the submission, so a multi-entry post is a flat comment list, not a
nested thread.
Plugs
No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the
trait defaults and return ProviderError::Unsupported.
Implementation Notes & Gotchas
- Pending state machine.
post_pending()stores the full subreddit list, the message, the first media path, its thumbnail, a cursor and the results inpending_data. It does not call Reddit.check_post_status()arms the next subreddit, andfinalize_post()publishes one subreddit per call and advances the cursor. This is deliberate: a crashed activity cannot replay the whole loop and duplicate already-published subreddits. - Armed marker before submit.
check_post_status()writesarmed: {sr, title, armedAt, media, submitted, lookups}before the workflow commits the state and callsfinalize_post(). If finalize dies mid-submit, the next check sees the marker and looks up the user’s listing instead of blindly resubmitting. - Recovery lookup.
find_submitted_post()callsGET /user/{username}/submitted?limit=25&sort=newon the API host, then matches subreddit, exact title andcreated_utc > armed_at - 60s. Only 25 newest submissions are inspected. Legacy rows withoutintegration.profilecannot recover and fall through to the grace budget. - Grace budgets. A media submit that was accepted but not yet visible keeps polling for up to 9 lookups (engine checks are about 20 seconds apart). An unaccepted media submit is not resubmitted until both 10 minutes have elapsed and 9 lookups have run. A self/link submit gets one grace lookup. When the budget expires, media records an empty post id and the subreddit URL; self/link disarms and re-arms so it can submit again.
- RATELIMIT is not a failure. Reddit returns HTTP 200 with
json.errors[0][0] == "RATELIMIT".finalize_post()treats an all-RATELIMIT response as “nothing was submitted”, disarms and returns pending; the next check re-arms later. Any other Reddit error becomes aBadBodywithReddit rejected the post to r/<sr>: <messages>. - Blocking compatibility path.
post()runspost_pending → check → finalizein a loop with 5-second sleeps and an 8-minute timeout. The engine uses the pending path becausesupports_pending = true; the blocking path is kept for pre-v1.0.6 workflows. - Malformed settings can complete empty.
post_pending()deserializessettings.subredditwithunwrap_or_default(). A caller that bypassesvalidate_settings()and sends a non-array value gets an empty subreddit list;check_post_status()then seescursor >= lenand reportsCompletedwith emptypostId/releaseURL. Always validate settings before creating a post. - Multi-subreddit result shape.
completed_response()joinspost_ids andreleaseURLs with commas. A media post whose lookup budget expired contributes an emptypost_idbut a valid subreddit URL. Consumers must split on commas, not assume one id. - Rate limits are per OAuth client id, not per user. Reddit’s free Data API
limit is 100 queries per minute per client id, averaged over 10 minutes.
Every Reddit channel on one Rustiz deployment shares that budget. The
provider sets
max_concurrent_job = 1to keep bursts under control, but a single media post costs several requests (asset, S3 upload, submit, possibly listing lookups). - User-Agent is generic. The shared HTTP client sends
rustiz/<crate version>and the Reddit provider does not override it. Reddit’s policy asks for a unique, descriptive User-Agent containing the platform, app id, version and a Reddit username contact. A generic agent can be aggressively throttled. If Reddit is a production channel, add a deployment-specific User-Agent (and keep it honest). - No PKCE.
generate_auth_url()creates acode_verifierfor the shared response shape but never sends it. Reddit’s web-app flow uses the client secret, so this is harmless, but the field is unused. - Token refresh is simple.
refresh_token()postsgrant_type=refresh_tokenwith the same Basic auth, calls/api/v1/meagain, and keeps the same refresh token. It does not callcheck_scopes(); a token whose scopes were reduced is only discovered when a missing endpoint returns an error. - Comment thread is flat.
last_comment_idis ignored. If a product requirement is “reply to the previous comment”, this provider does not do it. - Comment permalink can collapse.
release_urlis{REDDIT_WEB_BASE_URL}{permalink}. If Reddit omitspermalink, the URL becomes the bare web origin. restrictionspath bug. It derives the subreddit name withsubreddit.split_once("/r/")and falls back to the literal"undefined". Passingr/rust(no leading slash) therefore calls/api/v1/undefined/post_requirements. Always pass/r/<name>.subredditshides image-only communities. Search results withsubmission_type == "image"and non-public subreddits are filtered out, so a valid image-only community may not appear in the picker. Direct/r/<name>lookup applies the same filter.type: "link"URL is not required by validation. A settings object withtype: "link"and nourlpassesvalidate_settings()and fails only at Reddit with an error. Validate it upstream or in your agent prompt.- Title length is not checked. Reddit caps titles at 300 characters (subreddits can set a lower cap); Rustiz only enforces a 2-character minimum. The provider also caps the whole message at 10,000 characters, below Reddit’s 40,000-character text-post limit, so long-form text is truncated/rejected locally before Reddit sees it.
- Flair requirement is best-effort.
restrictionsreportsis_flair_requiredas true only when Reddit says flair is required and the flair list is non-empty. If the flair endpoint fails, Rustiz returns an empty list and treats flair as optional; the submit may then be removed by the subreddit’s AutoModerator. - Media upload is unauthenticated at the S3 step. The second request goes to
the URL Reddit returns, not to
oauth.reddit.com, and uses the shared HTTP client. A local mock server must return an absoluteaction; the code accepts absolute URLs for testability and prefixes protocol-relative URLs withhttps:. .mp4check is split.check_validity()checks lowercasemp4;finalize_post()uses case-insensitivehas_extension. An uppercase.MP4passes pre-flight but then requires a thumbnail and takes the video branch.- Reddit 401 handling.
Classifyis a no-op, so the shared HTTP layer maps a401toRefreshTokenand the engine refreshes once. A second failure flags the channel for reconnection. Reddit’s403(no API approval, banned subreddit, missing scope) is aBadBody, not a refresh. - API sunset. The provider still speaks the classic OAuth + Data API. It does not implement Devvit, Reddit’s new app platform. Expect this page to become migration guidance rather than setup guidance as 2027 approaches.
References
- Reddit app registration: https://www.reddit.com/prefs/apps
- OAuth2 (archived but still the flow Reddit uses): https://github.com/reddit-archive/reddit/wiki/OAuth2
- Responsible Builder Policy: https://support.reddithelp.com/hc/articles/42728983564564
- Data API wiki / rate limits: https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki
- Developer Platform (Devvit): https://developers.reddit.com/docs
- 2026 migration announcement: https://www.reddit.com/r/redditdev/comments/1wubcvf/moving_data_api_apps_to_the_developer_platform/
POST /api/submitreference: https://www.reddit.com/dev/api#POST_api_submit- Postiz Reddit setup: https://docs.postiz.com/providers/reddit