Whop (whop)
Whop is a marketplace/community platform. Rustiz connects a Whop user with OAuth 2.1 + PKCE and publishes forum posts to a Whop experience (forum). It can also publish comments/replies, upload images and other files through Whop’s three-step file API, and list the companies/forums the user can access.
- Rust source:
crates/rustiz-providers/src/whop.rs - Upstream port:
whop.provider.ts - Editor: Markdown
- Connection mode:
oauth - Tool tip: “Schedule posts to forums”
Overview
| Property | Value |
|---|---|
| Identifier | whop |
| Display name | Whop |
| Connection mode | oauth (authorization code + PKCE S256; public client) |
| Editor | markdown |
| Channel kind | social |
| Comments / threads | Yes. supports_comments = true; comments are forum posts with parent_id |
| Pending / async publishing | No. supports_pending is the default false; post() returns PostStatus::Success |
| Analytics | No |
| Post analytics | No |
| Mentions | No |
is_between_steps | false |
convert_to_jpeg | false |
max_concurrent_job | 3 (the code comments that Whop limits are per token) |
Character limit (max_length) | 50000 |
| API base | https://api.whop.com (WHOP_BASE_URL) |
| Web base | https://whop.com (WHOP_WEB_BASE_URL) |
| OAuth endpoints | https://api.whop.com/oauth/authorize, /oauth/token, /oauth/userinfo |
Server Environment Variables
required_env() returns exactly one variable:
#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
&["WHOP_CLIENT_ID"]
}
}
required_env_alternatives() is not overridden, so there are no aliases. The
client secret is never read.
| Variable | Required | Default | Purpose |
|---|---|---|---|
WHOP_CLIENT_ID | Yes | "" | OAuth app id (app_...). An empty value makes /public/v1/integrations/whop/connect fail before opening a broken login page. |
WHOP_BASE_URL | No | https://api.whop.com | Overrides the API and OAuth host. Set it to https://sandbox-api.whop.com to use the Whop sandbox. This is an origin, not the /api/v1 URL; the provider appends /api/v1 or /oauth/.... Trailing slashes are stripped. |
WHOP_WEB_BASE_URL | No | https://whop.com | Overrides the web host used only to build release_url (/experiences/{experience}/{post_id}). For sandbox, https://sandbox.whop.com. |
FRONTEND_URL | No | http://localhost:5000 | Public origin of this Rustiz deployment. The OAuth redirect URI is derived from it. Trailing slashes are stripped. |
JWT_SECRET | No | "" | Not used by the Whop OAuth flow itself (there is no encrypted clientInfo/customFields payload). Set it for the rest of the deployment. |
There is no WHOP_CLIENT_SECRET or WHOP_API_KEY variable in this
provider. Whop’s public-client PKCE flow does not send a secret.
rustiz providers --status reports these variable names (never values) and
marks Whop configured when WHOP_CLIENT_ID is non-empty.
Step-by-Step Developer App Setup
1. Create the app
Production: https://whop.com/dashboard/developer → create/select an app.
Sandbox: https://sandbox.whop.com/dashboard/developer → create a separate app. Sandbox and production share no accounts, keys, products, webhooks or OAuth apps. A sandbox client id cannot be used against production, and vice versa.
2. Add the exact redirect URI
Rustiz builds the redirect URI in redirect_uri():
#![allow(unused)]
fn main() {
format!("{}/integrations/social/whop", self.ctx.config.frontend_url())
}
frontend_url() trims trailing slashes. Register exactly:
{FRONTEND_URL}/integrations/social/whop
Examples:
https://social.example.com/integrations/social/whop
http://localhost:5000/integrations/social/whop
There is no trailing slash after whop, no www, and no redirectmeto.com
wrapping for Whop. Whop requires an exact match, including scheme, host, port
and path.
Rustiz sends this authorize URL (values URL-encoded where noted):
{WHOP_BASE_URL}/oauth/authorize
?response_type=code
&client_id={WHOP_CLIENT_ID}
&redirect_uri={urlencode(FRONTEND_URL/integrations/social/whop)}
&scope={urlencode(openid profile email forum:post:create forum:read company:basic:read)}
&state={6-char state}
&nonce={16-char nonce}
&code_challenge={S256(verifier)}
&code_challenge_method=S256
3. Copy the client id
Copy the app’s client_id (looks like app_xxxxxxxxx) into
WHOP_CLIENT_ID. The client secret is shown in the dashboard but Rustiz does
not use it: the authorize/token flow is the public-client PKCE flow from
Whop’s OAuth guide.
4. Select scopes / permissions
Rustiz sends this exact space-separated scope string in the authorize URL:
openid profile email forum:post:create forum:read company:basic:read
| Scope | Used for |
|---|---|
openid | OIDC login; required by Whop’s userinfo endpoint |
profile | name, preferred_username, picture |
email | email / email_verified on userinfo (not read by Rustiz, but requested) |
forum:post:create | POST /api/v1/forum_posts (posts and comments) |
forum:read | listing forums (GET /api/v1/forums) and reading forum data |
company:basic:read | listing companies (GET /api/v1/companies) |
In the Whop dashboard, open the app’s Permissions tab and add
forum:read, forum:post:create and company:basic:read. Whop requires a
justification for each permission and a required/optional choice. The
permissions flow is required even when testing on your own account.
check_scopes() is not called anywhere in whop.rs. Rustiz does not
verify the scope set that Whop actually granted; a missing scope surfaces as a
platform error on the first API call. Whop maps insufficient_scope to a
“please re-authenticate” error, but refreshing a token cannot add a scope — the
user must approve the new permission.
5. Sandbox / testing mode
Whop has a full sandbox:
| Sandbox | Production | |
|---|---|---|
| Dashboard | sandbox.whop.com/dashboard/developer | whop.com/dashboard/developer |
| API base | https://sandbox-api.whop.com | https://api.whop.com |
| OAuth authorize | https://sandbox-api.whop.com/oauth/authorize | https://api.whop.com/oauth/authorize |
| Web links | https://sandbox.whop.com | https://whop.com |
To test:
- Create the app in the sandbox dashboard and register the same redirect URI there.
- Set
WHOP_CLIENT_IDto the sandbox app id. - Set
WHOP_BASE_URL=https://sandbox-api.whop.comandWHOP_WEB_BASE_URL=https://sandbox.whop.com. - Connect a sandbox Whop account and post to a sandbox forum.
Do not mix sandbox and production values: the keys/app ids look identical but belong to isolated environments.
6. Production app review
A Whop app can be installed and used through its direct install/consent flow without being listed in the Whop App Store. App Store listing is a separate review:
- open the app in the Developer dashboard;
- select Publish app → Submit for review.
Whop checks that the app’s experience/dashboard view loads inside Whop, the listing has an icon, name, description, base URL, path and at least two gallery images, every permission has its own meaningful justification, and the app does not use Whop branding. A pure OAuth integration that only posts through the API does not need a listing to work for accounts that authorize it; listing is for discoverability.
Connecting a Channel (CLI & MCP)
CLI
rustiz channels connect whop
Useful flags:
rustiz channels connect whop --open # launch the login URL
rustiz channels connect whop --timeout 600 # seconds to wait (default 300)
Do not pass --fields or --external-url:
--fieldsswitches to thecustomFieldspath and base64-encodes the JSON as the OAuthcode; Whop’sauthenticate()expects a real authorization code, so it fails.--external-urlcallsexternal_url(), which Whop does not implement.
The CLI prints login url: ... on stderr (stdout is the JSON document) and,
when FRONTEND_URL points at http://localhost/127.0.0.1, serves the
callback in-process and waits for the channel to appear. With a public HTTPS
FRONTEND_URL the URL is returned with a note that the server handles the
callback.
Immediate OAuth response shape:
{
"connected": false,
"pending": true,
"requiresHuman": true,
"connectionId": "Ab12Cd",
"url": "https://api.whop.com/oauth/authorize?...",
"message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}
MCP
Start:
{ "provider": "whop" }
Poll:
{ "connectionId": "Ab12Cd" }
connectChannelTool returns the URL and connectionId. The user opens the
URL, approves the app, and the agent polls with the same connectionId until
connected: true, then calls integrationList for the channel id.
Do not pass customFields, externalUrl, name or description for Whop.
custom_fields() is not overridden, so integrationSchema.customFields is an
empty array: there are no keys or validation regexes.
Selecting a company and forum (not channelSetupTool)
Whop has is_between_steps = false, so there is no post-connect
channelSetupTool flow and the channel is saved immediately. Rustiz does not
store the chosen company/forum on the integration; both ids belong in each
post’s settings.
Two helper functions are exposed through triggerTool:
| Function | Parameters | Returns |
|---|---|---|
companies | {} | [{"id": "biz_...", "name": "<company title>"}] |
experiences | {"id": "<company id>"} | [{"id": "exp_...", "name": "<forum/experience name>"}] |
companies calls GET /api/v1/companies?first=50; experiences calls
GET /api/v1/forums?company_id={company}&first=50. Both read only the first
50 records and both swallow errors, returning [] on any failure.
MCP examples:
{
"integrationId": "int_...",
"methodName": "companies",
"dataSchema": []
}
{
"integrationId": "int_...",
"methodName": "experiences",
"dataSchema": [{ "key": "id", "value": "biz_xxxxxxxxx" }]
}
The experiences parameter key is id, not company or companyId.
For a forum whose nested experience object is missing, the tool falls back to
the forum id for both id and name (it does not use the forum title).
Post Settings & Media Validation
settings_schema()
| Field | Type | Required | Validation / behavior |
|---|---|---|---|
company | string | Yes | minLength: 1. Company id used to locate the forum. It is not sent in the create-post body. |
experience | string | Yes | minLength: 1. Sent as experience_id to POST /api/v1/forum_posts. This is the actual forum target. |
title | string | No | Optional forum post title. Omitted when empty; comments never send it. |
additionalProperties: true.
Example:
{
"__type": "whop",
"company": "biz_xxxxxxxxxxxx",
"experience": "exp_xxxxxxxxxxxx",
"title": "Weekly update"
}
The simple CLI form cannot pass settings; use rustiz posts create --json post.json with a CreatePostDto (posts[].settings). MCP settings array:
{
"settings": [
{ "key": "company", "value": "biz_xxxxxxxxxxxx" },
{ "key": "experience", "value": "exp_xxxxxxxxxxxx" },
{ "key": "title", "value": "Weekly update" }
]
}
validate_settings() rules
companymust exist and be a non-empty string.experiencemust exist and be a non-empty string.- If
titleis present and notnull, it must be a string. An empty string is accepted and then omitted at publish time.
Publish behavior
post() uses only the first PostDetails:
- Reads
experiencefrom settings; missing/empty is a hard error. - Uploads every media item (see below) and collects
{ "id": "<file id>" }. - Sends:
{
"experience_id": "exp_...",
"content": "<post message, Markdown>",
"title": "<optional>",
"attachments": [{ "id": "file_..." }]
}
company is not included. The response id becomes post_id; release_url is:
{WHOP_WEB_BASE_URL}/experiences/{experience}/{post_id}
status is PostStatus::Success (not Completed).
Comments / threads
comment():
- reads
experiencefrom the comment’s settings (required); - sets
parent_idtolast_comment_idwhen supplied, otherwise to the originalpost_id; - reuses the same
POST /api/v1/forum_postsendpoint; - never sends
title; - returns
release_urlpointing at the original post, not the comment.
Because the engine passes the previous comment id as last_comment_id, a
multi-part thread becomes a chain of nested replies on Whop.
Media requirements
- No
check_validity()override: Rustiz accepts any media type and count. - Each media item is uploaded with Whop’s file API:
POST /api/v1/fileswith{"filename": "<last path segment>"};PUTthe bytes toupload_url, copyingupload_headersfrom the response;- poll
GET /api/v1/files/{id}every 5 seconds untilupload_status == "ready".
- The poll gives up after 108 attempts (about 9 minutes) with
File upload timed out;upload_status == "failed"fails immediately. - The provider sets
Content-Lengthitself, then applies Whop’supload_headers; the code intends Whop’s own values (including a length) to win if they are present. - The
PUTdoes not set aContent-Type; it relies on the presigned headers Whop returns. - Attachments are sent as
{ "id": ... }only; alt text and thumbnails are ignored. - If the create-file response has no
upload_url, the file id is attached without uploading anything (likely an empty/broken attachment). - Whop’s own attachment count/type limits are not enforced by the provider.
Plugs
No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the
trait defaults and return ProviderError::Unsupported. There is no
resolve_release_id, release_url override, or missing() implementation.
Implementation Notes & Gotchas
- Public client only. The token exchange sends
client_id+code_verifierand noclient_secret; the refresh request sends onlyclient_id+refresh_token. This matches Whop’s public PKCE flow. An app configured as a confidential client that requires a secret will fail at token exchange. - Refresh tokens rotate. Store the new refresh token from every refresh.
token_details()always setsrefresh_tokentoSome(...), even if Whop omitted it (then it is an empty string and the next refresh fails). - Access tokens last one hour.
token_details()falls back to3600seconds when Whop omitsexpires_inor sends0.refresh_cron = false, so there is no proactive hourly sweep; the engine refreshes reactively when a call returns 401 or aninvalid_grant/insufficient_scopebody. company_id-scoped tokens cannot be refreshed correctly. If the authorize request usedcompany_id, Whop requires the samecompany_idon refresh.refresh_token()does not send one, so refresh fails and the channel needs reconnecting. Rustiz never sendscompany_iditself, but a manually built authorize URL could.- Reconnect appends
?refresh=to the redirect URI. When a channel is reconnected with?refresh=<internal id>,authenticate()sendsredirect_uri = "<registered uri>?refresh=<id>". Whop requires an exact redirect URI match, so this token exchange can be rejected. A fresh connect (no refresh) is unaffected. - Scope check is absent.
info.scopesis only used to build the authorize URL. There is nocheck_scopes()call and no post-callback validation. The first API call reveals a missing permission. insufficient_scoperequests a refresh, but only re-consent fixes it. The error text tells the user to re-authenticate with the required scopes.- Error mapping is substring-based.
handle_errors()checks the body forinvalid_grant,insufficient_scope,invalid_requestandnot_found. An HTTP 401 with no classification is also mapped to a refresh by the shared HTTP layer; a 429/500 is retried automatically (up to 3 retries / 4 attempts, with the 5-second production delay between attempts). - Rate limit. Whop documents 600 authenticated API requests per minute per
operation and API credential. A 429 carries a retry window; the shared client
retries 429 and 500 responses and also recognizes
rate_limit_exceeded/Rate limitmarkers in a 200/4xx body. - Uploads bypass the retry client. The
PUTto the presigned URL usesexecute()directly, so a transient 429/5xx during the byte upload is not retried. The status polling uses the retrying client. companyis required but unused when publishing. It exists so the composer/tool can resolve the forum. Onlyexperiencereaches the API.- The special
experience_id: "public"target is unsupported. Whop requiresaccount_idwhenexperience_idispublic;forum_body()never sendsaccount_id, so a literalpublicexperience fails. - Only the first post is published.
post()ignoresposts[1..]; additional parts must be comments. - Comments inherit the main post’s experience. If the comment settings
omit
experience, the call fails even thoughpost_idis known. release_urlis constructed, not verified. It uses the experience id and post id in a path; Whop’s canonical forum URL may differ. Treat it as a best-effort link.- Helpers silently return
[]. Any network/JSON error incompaniesorexperiencesis swallowed, so an empty list can mean “no access”, “bad token”, or “Whop changed the response shape”. - Tool ids and settings types.
companiesreturnsidas Whop sends it (normally abiz_...string).validate_settings()requires a string; if a number ever appears, stringify it before scheduling. - Token errors are redacted.
without_request_body()replaces the error body with{}forBadBody/RefreshTokenso the authorization code or refresh token is not persisted in the error record.
References
- Whop OAuth guide: https://docs.whop.com/developer/guides/oauth
- Whop permissions: https://docs.whop.com/developer/guides/permissions
- Whop forums guide: https://docs.whop.com/developer/guides/forums
- Create forum post: https://docs.whop.com/api-reference/forum-posts/create-forum-post
- Create file / direct uploads: https://docs.whop.com/api-reference/files/create-file
- Sandbox vs production: https://docs.whop.com/developer/quickstart
- App Store listing requirements: https://docs.whop.com/developer/apps/listing-requirements
- Whop rate limits / troubleshooting: https://docs.whop.com/developer/troubleshooting
- Postiz Whop settings: https://docs.postiz.com/public-api/providers/whop