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

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

PropertyValue
Identifierwhop
Display nameWhop
Connection modeoauth (authorization code + PKCE S256; public client)
Editormarkdown
Channel kindsocial
Comments / threadsYes. supports_comments = true; comments are forum posts with parent_id
Pending / async publishingNo. supports_pending is the default false; post() returns PostStatus::Success
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job3 (the code comments that Whop limits are per token)
Character limit (max_length)50000
API basehttps://api.whop.com (WHOP_BASE_URL)
Web basehttps://whop.com (WHOP_WEB_BASE_URL)
OAuth endpointshttps://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.

VariableRequiredDefaultPurpose
WHOP_CLIENT_IDYes""OAuth app id (app_...). An empty value makes /public/v1/integrations/whop/connect fail before opening a broken login page.
WHOP_BASE_URLNohttps://api.whop.comOverrides 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_URLNohttps://whop.comOverrides the web host used only to build release_url (/experiences/{experience}/{post_id}). For sandbox, https://sandbox.whop.com.
FRONTEND_URLNohttp://localhost:5000Public origin of this Rustiz deployment. The OAuth redirect URI is derived from it. Trailing slashes are stripped.
JWT_SECRETNo""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
ScopeUsed for
openidOIDC login; required by Whop’s userinfo endpoint
profilename, preferred_username, picture
emailemail / email_verified on userinfo (not read by Rustiz, but requested)
forum:post:createPOST /api/v1/forum_posts (posts and comments)
forum:readlisting forums (GET /api/v1/forums) and reading forum data
company:basic:readlisting 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:

SandboxProduction
Dashboardsandbox.whop.com/dashboard/developerwhop.com/dashboard/developer
API basehttps://sandbox-api.whop.comhttps://api.whop.com
OAuth authorizehttps://sandbox-api.whop.com/oauth/authorizehttps://api.whop.com/oauth/authorize
Web linkshttps://sandbox.whop.comhttps://whop.com

To test:

  1. Create the app in the sandbox dashboard and register the same redirect URI there.
  2. Set WHOP_CLIENT_ID to the sandbox app id.
  3. Set WHOP_BASE_URL=https://sandbox-api.whop.com and WHOP_WEB_BASE_URL=https://sandbox.whop.com.
  4. 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:

  • --fields switches to the customFields path and base64-encodes the JSON as the OAuth code; Whop’s authenticate() expects a real authorization code, so it fails.
  • --external-url calls external_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:

FunctionParametersReturns
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()

FieldTypeRequiredValidation / behavior
companystringYesminLength: 1. Company id used to locate the forum. It is not sent in the create-post body.
experiencestringYesminLength: 1. Sent as experience_id to POST /api/v1/forum_posts. This is the actual forum target.
titlestringNoOptional 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

  1. company must exist and be a non-empty string.
  2. experience must exist and be a non-empty string.
  3. If title is present and not null, it must be a string. An empty string is accepted and then omitted at publish time.

Publish behavior

post() uses only the first PostDetails:

  1. Reads experience from settings; missing/empty is a hard error.
  2. Uploads every media item (see below) and collects { "id": "<file id>" }.
  3. 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 experience from the comment’s settings (required);
  • sets parent_id to last_comment_id when supplied, otherwise to the original post_id;
  • reuses the same POST /api/v1/forum_posts endpoint;
  • never sends title;
  • returns release_url pointing 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:
    1. POST /api/v1/files with {"filename": "<last path segment>"};
    2. PUT the bytes to upload_url, copying upload_headers from the response;
    3. poll GET /api/v1/files/{id} every 5 seconds until upload_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-Length itself, then applies Whop’s upload_headers; the code intends Whop’s own values (including a length) to win if they are present.
  • The PUT does not set a Content-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_verifier and no client_secret; the refresh request sends only client_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 sets refresh_token to Some(...), 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 to 3600 seconds when Whop omits expires_in or sends 0. refresh_cron = false, so there is no proactive hourly sweep; the engine refreshes reactively when a call returns 401 or an invalid_grant/insufficient_scope body.
  • company_id-scoped tokens cannot be refreshed correctly. If the authorize request used company_id, Whop requires the same company_id on refresh. refresh_token() does not send one, so refresh fails and the channel needs reconnecting. Rustiz never sends company_id itself, but a manually built authorize URL could.
  • Reconnect appends ?refresh= to the redirect URI. When a channel is reconnected with ?refresh=<internal id>, authenticate() sends redirect_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.scopes is only used to build the authorize URL. There is no check_scopes() call and no post-callback validation. The first API call reveals a missing permission.
  • insufficient_scope requests 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 for invalid_grant, insufficient_scope, invalid_request and not_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 limit markers in a 200/4xx body.
  • Uploads bypass the retry client. The PUT to the presigned URL uses execute() directly, so a transient 429/5xx during the byte upload is not retried. The status polling uses the retrying client.
  • company is required but unused when publishing. It exists so the composer/tool can resolve the forum. Only experience reaches the API.
  • The special experience_id: "public" target is unsupported. Whop requires account_id when experience_id is public; forum_body() never sends account_id, so a literal public experience fails.
  • Only the first post is published. post() ignores posts[1..]; additional parts must be comments.
  • Comments inherit the main post’s experience. If the comment settings omit experience, the call fails even though post_id is known.
  • release_url is 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 in companies or experiences is swallowed, so an empty list can mean “no access”, “bad token”, or “Whop changed the response shape”.
  • Tool ids and settings types. companies returns id as Whop sends it (normally a biz_... 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 {} for BadBody/RefreshToken so the authorization code or refresh token is not persisted in the error record.

References