API GuidesAugust 13, 202610 min readingMarcel Czuryszkiewicz

Reddit OAuth API

Reddit's OAuth flow has one parameter that decides whether you ever receive a refresh token. Without it the integration dies sixty minutes after it starts working. Covers duration=permanent, the app type you cannot change later, a scope-to-endpoint table with three traps, and the User-Agent myth.

The Reddit OAuth API is quick to get working and easy to get permanently wrong. Three decisions are effectively irreversible: the app type you register, whether your authorization URL carries duration=permanent, and the User-Agent you send. Miss the second one and the integration works flawlessly for sixty minutes, then dies on every connected account with no fix available on your side.

Verified 04.08.2026 against Reddit's archived OAuth2 wiki, the endpoint reference at reddit.com/dev/api, and our own production integration.

A single brass key on a white string lying on a wooden table, photographed at an angle with shallow depth of field
One parameter decides whether the key you get opens the door once or every hour.

Pick the app type first, because you cannot change it later

Reddit's developer portal asks for an app type before anything else, and the answer is baked into the client_id it hands back.

TypeGets a client secret?GrantFor
web appYesAuthorization codeServer-side integrations. Pick this for a backend
installed appNoAuthorization code, public clientApps running on the user's own device
scriptYesPassword grant, own account onlyPersonal bots on accounts you control

The official wiki is blunt about the middle row. An installed app "Cannot keep a secret, and therefore, does not receive one."

That is the trap. Tick "installed app" because your product happens to be a mobile app, and the token exchange below has nothing to authenticate with: there is no secret for the Basic header. You cannot switch afterwards. The fix is a new application, a new client_id, and every existing user back through consent. Decide this in the first five minutes.

The authorization URL, parameter by parameter

https://www.reddit.com/api/v1/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://api.example.com/integrations/reddit/callback
  &scope=identity%20read%20submit%20edit%20mysubreddits%20history%20modposts%20flair
  &duration=permanent
  &state=oauth%3A0f1c9a2e
  • response_type=code is the only value that makes sense for a web app.
  • redirect_uri must match what you registered character for character, trailing slash included.
  • scope is space separated in the request. What comes back is not always.
  • duration is the whole next section.
  • state is not decoration. We generate oauth:<uuid>, store the team id, the connecting user id and the post-callback redirect against it in Redis with a 30 minute TTL, and reject any callback whose state is missing from the cache. That value carries the tenant: without it you get a code and no idea whose account it belongs to.

One note on PKCE, since every generic OAuth guide insists on it. In our integration code_challenge and code_challenge_method are commented out of the authorization URL while the token request still sends a code_verifier, and the exchange succeeds anyway. Whatever the history there, the practical position is the same: the client secret in the Basic header is what authenticates your app. Do not design as though PKCE is protecting this flow.

A retro flip clock in black and white against a white wall, showing 12:20 AM with the minute card caught mid-flip
Reddit bearer tokens expire after one hour. Everything else follows from that.

duration=permanent: the one-hour time bomb

duration defaults to temporary. Here is the entire difference it makes.

Without duration=permanent:

{
  "access_token": "...",
  "token_type": "bearer",
  "expires_in": 3600,
  "scope": "identity read submit edit mysubreddits history modposts flair"
}

With duration=permanent:

{
  "access_token": "...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "identity read submit edit mysubreddits history modposts flair"
}

One field. The archived official wiki states the consequence plainly: "All bearer tokens expire after 1 hour."

That number needs a caveat, because sources disagree. The wiki says one hour, our code stores whatever expires_in Reddit returns and falls back to 3600 only when the field is absent, and several guides currently ranking for this topic claim 24 hours. Read the field. Do not hard code either number.

Now the part that makes a missing duration the most expensive mistake on this platform. With no refresh_token there is nothing to recover with. Not a deploy, not a migration, not a support ticket. The only route back is sending every affected user through the consent screen again, and you learn about it roughly sixty-one minutes after the first successful connection, on other people's accounts, in production.

The refresh token is issued once, and only if you asked correctly. The cross-platform version of that problem is in OAuth token refresh for social media APIs.

Exchanging the code: Basic auth, not a body field

curl -X POST "https://www.reddit.com/api/v1/access_token" \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -H "User-Agent: your-app-identifier" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=${CODE}" \
  -d "redirect_uri=https://api.example.com/integrations/reddit/callback"

Three things go wrong here regularly. The credentials belong in the Authorization: Basic header, which is what -u produces, not in the form body. The body is x-www-form-urlencoded, not JSON. And the token endpoint lives on www.reddit.com while every authenticated call afterwards goes to oauth.reddit.com; swapping the two produces a 401 or 404 that reads like a credentials problem and is not one.

Then a detail we found in production and have not seen documented anywhere: Reddit returns the granted scope comma separated in some responses and space separated in others. Our parser branches on it.

const scopes = response.data.scope.includes(",")
  ? response.data.scope.split(",")
  : response.data.scope.split(" ");

Split on the wrong separator and you get a one-element array, conclude that a valid authorisation is missing every scope you asked for, and reject a connection that was fine.

bundle.social

Reddit, plus fourteen other platforms, through one API.

duration=permanent, a granted-scope check on connect, and hour-long tokens refreshed in the background.

An open drawer with wooden dividers, copper forks and spoons in the near compartments and black cutlery in the far ones
Every scope opens exactly one compartment. Ask for the wrong one and the call fails.

Scopes and what they actually unlock

Our production scope string is identity read submit edit mysubreddits history modposts flair. Reddit documents the required scope beside every endpoint in its API reference. The mapping is not the one you would guess.

ScopeEndpoints it unlocksNeeded for
identityGET /api/v1/meWho connected
readGET /r/{subreddit}/aboutSubreddit metadata, including whether flair is mandatory
submitPOST /api/submit, GET /api/v1/{subreddit}/post_requirementsPublishing, and a subreddit's posting rules
editPOST /api/editusertext, POST /api/delEditing and deleting your own posts
flairGET /r/{subreddit}/api/link_flair_v2, POST /r/{subreddit}/api/selectflairListing link flair and setting it
mysubredditsGET /subreddits/mine/subscriber, GET /subreddits/mine/moderatorThe subreddits a user can post to
modpostsPOST /api/approve, POST /api/removeModeration actions
historyGET /user/{username}/submittedAn account's own submissions

Three rows do not behave the way their names suggest. post_requirements is a plain GET that returns rules, and it sits behind submit, not read. link_flair_v2 sits behind flair, not read. And subreddits/mine/* needs mysubreddits, so an integration that asked only for read cannot list the subreddits its own user can post to.

Those first two are not academic. They are exactly the calls you make before publishing: fetch the post requirements, fetch the available link flair, attach a flair_id. Skip them and a flair-required subreddit rejects the submission with SUBMIT_VALIDATION_FLAIR_REQUIRED, a code we keep on a hard non-retryable list because the identical payload will fail identically forever.

Finally: read the granted scopes out of the token response and compare them against what you requested. We abort the connection when anything is missing rather than creating an account that fails on its first publish.

The User-Agent header: what is actually enforced

Most guides say Reddit demands platform:app-id:version (by /u/username), and that a generic User-Agent earns an immediate 429.

Our production User-Agent is a single token, bundlesocial, defined once in a constant and attached to every Reddit request including the token exchange. Publishing has worked on it for years.

Be precise about what that proves. Not that the format is irrelevant: we do not read the X-Ratelimit-* headers on this integration, so we cannot rule out a quieter, lower budget applied because of it. What we can say is that it has never produced 429s or blocks on our traffic.

The rule that holds either way: one stable string that uniquely identifies your application, set in exactly one place, never your HTTP client's default. axios/1.7.2 and python-requests/2.31.0 are shared by hundreds of thousands of clients and are what actually attracts throttling. The documented format costs nothing, so adopt it if you are starting today.

One thing explains the confusion: the OAuth2 wiki does not mention User-Agent once, and neither does the endpoint reference. The requirement sits in a separate access-rules document.

Refreshing: the 30-minute window

curl -X POST "https://www.reddit.com/api/v1/access_token" \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -H "User-Agent: your-app-identifier" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=${REFRESH_TOKEN}"

At 3600 seconds a nightly job is useless and an hourly job races the expiry. Our scanner picks up accounts whose token expires within the next 30 minutes and skips any account already tried in the last 10 minutes, so a broken account backs off instead of being hammered every pass.

Separate the two failure modes, because they need opposite handling. A 400 or 401 on the refresh grant means the grant is gone: the user revoked or deauthorised the app. Retrying never helps, so mark the account as needing reconnection. A 5xx is Reddit, and belongs in a retry with backoff. The shape of that split is in social media API error handling.

On rotation: we overwrite the stored refresh token only when the response actually carries a new one. Reddit has not rotated it on us in practice, but the conditional write is right regardless, because being wrong about rotation costs the account permanently.

Rate limits, in one paragraph

Reddit reports your remaining budget in the X-Ratelimit-Used, X-Ratelimit-Remaining and X-Ratelimit-Reset response headers. Read those rather than trusting a number from a guide: the figures in circulation disagree, 60 and 100 requests per minute both appear in articles published this year, and the budget is generally described as counted per OAuth client id rather than per user, which matters if you are multi-tenant. We are not restating a number, because we could not confirm one at source. The cross-platform view is in social media API rate limits.

The shorter path

An app type you cannot change, one parameter that decides whether you ever receive a refresh token, three counter-intuitive scope rows, a User-Agent requirement documented outside the auth docs, and an hour-long token that needs a tighter scanner than most schedulers default to.

The Reddit API integration at bundle.social covers that: the full scope set with duration=permanent, a granted-scope check that refuses to create an account missing a permission, encrypted token storage, and the background refresh that keeps hour-long tokens alive. When a grant is revoked, the account moves to an explicit disconnected state with a reason attached, so your UI can say what happened instead of retrying a dead token.

Who this is not for: if you only need one subreddit's public listings for a research project, you do not need OAuth at all, let alone us. If you are connecting Reddit alongside a dozen other networks, the whole problem is mapped in the social media API integration guide.

Frequently asked questions

Why does my Reddit access token expire after one hour?

Because that is the documented lifetime. Reddit's OAuth2 wiki states that all bearer tokens expire after 1 hour, and expires_in reflects it. Store the value Reddit returns rather than hard coding 3600, and treat guides claiming 24 hours as contradicted by the official source.

How do I get a refresh token from the Reddit API?

Add duration=permanent to the authorization URL. It defaults to temporary, and a temporary authorisation returns an access token with no refresh_token at all. There is no way to obtain one afterwards: the user has to go through the consent screen again, with the corrected URL.

What scopes do I need to post to Reddit?

submit to publish and to read post_requirements, flair for link_flair_v2 and selectflair, mysubreddits to list the subreddits the user can post to, and identity to identify the account. read alone covers none of those, despite the name.

Does the Reddit API require a specific User-Agent format?

The documented format is platform:app-id:version (by /u/username). Our production User-Agent is the single token bundlesocial, and publishing has worked on it for years. Follow the documented format if you are starting fresh; what clearly matters is a stable, unique string that is not your HTTP library's default.

Why does the Reddit API return 401 Unauthorized?

Four usual causes: the token expired after its hour, you called oauth.reddit.com with credentials meant for www.reddit.com or the reverse, the client secret went into the request body instead of the Basic header, or the user revoked the authorisation, which also produces 400 or 401 on refresh.

Marcel Czuryszkiewicz
Written by

Marcel Czuryszkiewicz

Co-Founder

Marcel is one of the builders behind bundle.social, creating a social media API with a strong focus on developer experience, reliability, and real support. Before bundle.social, he worked at Samsung R&D, Reply AI, and Docplanner, building internal tools that helped companies grow. He brought that same practical, product-focused mindset to bundle.social, so you can trust that your project is in good hands.