Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Identifierreddit
Display nameReddit
Connection modeoauth (a human opens a Reddit consent page)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra posts are flat top-level comments on the submission, not nested reply chains
Pending / async publishingYes. supports_pending = true; post_pending records the subreddit list, finalize_post publishes exactly one subreddit per call
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job1 — Reddit’s write limits are strict and shared per OAuth client
Character limit (max_length)10000 characters, hard-coded
OAuth scopesread, identity, submit, flair
Redirect URI{FRONTEND_URL}/integrations/social/reddit
API hosthttps://oauth.reddit.com (override REDDIT_BASE_URL)
OAuth / token hosthttps://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.

VariableRequiredDefaultPurpose
REDDIT_CLIENT_IDYes—Client id shown under the app name at reddit.com/prefs/apps
REDDIT_CLIENT_SECRETYes—App secret shown on the app card
REDDIT_BASE_URLNohttps://oauth.reddit.comAPI origin for /api/v1/me, /api/submit, subreddit lookups, media assets, comments. A trailing slash is stripped.
REDDIT_WEB_BASE_URLNohttps://www.reddit.comOrigin used for /api/v1/authorize and /api/v1/access_token, and to build release URLs. A trailing slash is stripped.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the OAuth redirect URI. Trailing slashes are stripped.
JWT_SECRETYes in practiceemptyEncrypts 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

  1. Sign in to Reddit with the account that will own the app.
  2. Open https://www.reddit.com/prefs/apps (or https://old.reddit.com/prefs/apps).
  3. Scroll to Developed Applications and click create another app / are you a developer? create an app.
  4. Fill in:
    • name — e.g. Rustiz or MyRustizInstance.
    • 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) or script (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.
  5. 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
ScopeWhy Rustiz needs it
read/subreddits/search, /r/{name}/about, /user/{name}/submitted, post requirements and flair listings
identityGET /api/v1/me during connect (id, name, avatar)
submitPOST /api/submit and POST /api/comment
flairReading 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.

  1. 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.
  2. 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 private r/rustiz_test) so a bad post cannot hit a real community. Reddit’s site-wide rules and each subreddit’s own rules still apply.
  3. 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.
  4. A successful OAuth consent is not API approval. Without approved API access, the consent page may work while oauth.reddit.com returns 401, 403 or throttled traffic.
  5. 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.
  6. 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; --open launches 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 --timeout seconds (default 300) for the channel to appear.
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll with rustiz channels connect-status <connectionId> or rustiz channels list.
  • On success the JSON document contains connected: true and the new channel’s id, identifier, name, profile, picture and refreshNeeded fields.

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 generic connect path would base64-encode the object and pass it as the OAuth code, which Reddit will reject. Reddit is OAuth only.
  • externalUrl — Reddit is not self-hosted. The generic path calls the provider’s external_url(), which returns Unsupported("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

  • subreddit must be an array with at least one entry.
  • Every entry must have a value object.
  • value.subreddit must be at least 2 characters. The error says “Subreddit must start with /r”, but the code only checks length. rust and /r/rust both pass.
  • value.title must be at least 2 characters.
  • value.type must be one of self, link, media.
  • For type: "link", if url is present it must parse as http/https, or contain the literal (post:. A missing url key is not rejected (the check is skipped); an empty string is rejected as Invalid URL.
  • If is_flair_required is truthy, flair.id and flair.name must both be non-empty.
  • If is_flair_required is false, an attached flair is still sent if present.

check_validity() rules

Two pre-flight errors are returned as strings:

  1. 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 uses posts.first() only, so it is really “exactly one media in the main post”, not per subreddit.
  2. If any post in any group has an .mp4 path 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

TypeAccepted by the providerNotes
type: "self"No media neededText post; media attached to the engine post is ignored
type: "link"No media neededurl is sent as the link destination
type: "media", imageExactly 1 imageUploaded to Reddit’s S3 asset endpoint, then submitted as kind=image
type: "media", videoExactly 1 .mp4Submitted as kind=video; a thumbnail is mandatory and uploaded as video_poster_url

The upload flow is:

  1. POST {REDDIT_BASE_URL}/api/media/asset with multipart fields filepath and mimetype.
  2. Reddit returns args.action (often protocol-relative, //…) and args.fields.
  3. Rustiz re-uploads the file as multipart to the S3 action and reads the <Location> element from the XML response.
  4. The returned URL is sent as url in /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 in pending_data. It does not call Reddit. check_post_status() arms the next subreddit, and finalize_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() writes armed: {sr, title, armedAt, media, submitted, lookups} before the workflow commits the state and calls finalize_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() calls GET /user/{username}/submitted?limit=25&sort=new on the API host, then matches subreddit, exact title and created_utc > armed_at - 60s. Only 25 newest submissions are inspected. Legacy rows without integration.profile cannot 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 a BadBody with Reddit rejected the post to r/<sr>: <messages>.
  • Blocking compatibility path. post() runs post_pending → check → finalize in a loop with 5-second sleeps and an 8-minute timeout. The engine uses the pending path because supports_pending = true; the blocking path is kept for pre-v1.0.6 workflows.
  • Malformed settings can complete empty. post_pending() deserializes settings.subreddit with unwrap_or_default(). A caller that bypasses validate_settings() and sends a non-array value gets an empty subreddit list; check_post_status() then sees cursor >= len and reports Completed with empty postId/releaseURL. Always validate settings before creating a post.
  • Multi-subreddit result shape. completed_response() joins post_ids and releaseURLs with commas. A media post whose lookup budget expired contributes an empty post_id but 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 = 1 to 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 a code_verifier for 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() posts grant_type=refresh_token with the same Basic auth, calls /api/v1/me again, and keeps the same refresh token. It does not call check_scopes(); a token whose scopes were reduced is only discovered when a missing endpoint returns an error.
  • Comment thread is flat. last_comment_id is ignored. If a product requirement is “reply to the previous comment”, this provider does not do it.
  • Comment permalink can collapse. release_url is {REDDIT_WEB_BASE_URL}{permalink}. If Reddit omits permalink, the URL becomes the bare web origin.
  • restrictions path bug. It derives the subreddit name with subreddit.split_once("/r/") and falls back to the literal "undefined". Passing r/rust (no leading slash) therefore calls /api/v1/undefined/post_requirements. Always pass /r/<name>.
  • subreddits hides image-only communities. Search results with submission_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 with type: "link" and no url passes validate_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. restrictions reports is_flair_required as 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 absolute action; the code accepts absolute URLs for testability and prefixes protocol-relative URLs with https:.
  • .mp4 check is split. check_validity() checks lowercase mp4; finalize_post() uses case-insensitive has_extension. An uppercase .MP4 passes pre-flight but then requires a thumbnail and takes the video branch.
  • Reddit 401 handling. Classify is a no-op, so the shared HTTP layer maps a 401 to RefreshToken and the engine refreshes once. A second failure flags the channel for reconnection. Reddit’s 403 (no API approval, banned subreddit, missing scope) is a BadBody, 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