External Club Integration
Provision external-club users, validate bcrypt password hashes, and understand webhook enrichment on /oauth/userinfo.
Local portal docs
This guide documents the current Portal.Service contract for partner apps that provision or link users through the external_club flow.
Summary
Partners can call POST /:uiName/external-club/user to create or update a linked portal user. If hub integration is enabled, Portal.Service also creates or logs in the hub user and adds them to the configured hub club.
The OAuth client used for this request must be active and allowed to use the external_club scope.
Partners can also:
- call
POST /oauth/external-club/sessionto mint a TCM session token for a linked user - call
POST /oauth/external-club/session/resolveto validate that token later and resolve the linked TCM user from their own backend
If you are looking for the simplest hosted-widget validation flow, use Embedded Widget Auth.
Required request contract
Endpoint
POST /:uiName/external-club/userRequired fields
userNameemailclubNameclubUserIdhubClubIdclientIdclientSecretencryptedPassword
Optional fields
webhookEventEndpointavatarUrl
encryptedPassword must be a bcrypt hash. Portal.Service rejects non-bcrypt values.
Example payload
{
"userName": "charades_diablo4traders",
"email": "[email protected]",
"encryptedPassword": "$2b$10$.................................................",
"clubName": "charadescollective",
"clubUserId": "113769124091223507890",
"hubClubId": "11735",
"clientId": "tcm_xxx",
"clientSecret": "your-oauth-client-secret",
"webhookEventEndpoint": "https://pokecharades.example.com/api/v1/public/external-club/user-context",
"avatarUrl": "https://partner.example.com/path/to/google-avatar.webp"
}Validation and behavior
- The OAuth client credentials are validated before user provisioning starts.
- The client must include
external_clubinallowedScopes. - Existing users can be matched by the partner mapping (
clubNameplusclubUserId) or by normalized email. Portal.Servicepersists the external-club metadata, the OAuth client id, and the stored password hash on the linked user.- If
avatarUrlis supplied,Portal.Servicestores it on the linked user and may attempt a best-effort Hub avatar sync when the Hub member does not already have a profile image. - The response includes the resolved
hubUserId,hubClubId,oauthClientId, and the stored password hash.
Hub avatar syncing is non-blocking. A failed avatar upload does not fail the external-club provisioning request or the partner login flow.
Base URL and tenant path
The root base URL should not include the tenant path. The uiName segment is part of the request route.
EXTERNAL_CLUB_BASE_URL=https://www.thecrimsonmarket.com
EXTERNAL_CLUB_UI_NAME=manaResulting endpoint:
https://www.thecrimsonmarket.com/mana/external-club/userDevelopment base URL:
EXTERNAL_CLUB_BASE_URL=https://dev.portal.raum.au
EXTERNAL_CLUB_UI_NAME=manaDevelopment endpoint:
https://dev.portal.raum.au/mana/external-club/userEmbedded session bridge
Use this when your site needs to create a TCM-authenticated session for a linked external-club user.
Create a session token
POST /oauth/external-club/sessionTenant-prefixed deployments may also expose:
POST /:uiName/oauth/external-club/sessionAuthenticate with HTTP Basic auth using your OAuth clientId and clientSecret.
Request body:
{
"externalUserId": "partner-user-123"
}Success response:
{
"token": "tcm_user_token",
"refreshToken": "tcm_refresh_token",
"userId": "portal-user-id"
}The returned token is a TCM session token for the linked user. Keep it short-lived and treat it like a login credential.
Keeping embedded iframes logged in
Partner sites should keep their own users logged in with their own session system, then use the external-club bridge to create a TCM session only when an embedded TCM iframe needs it.
Do not depend on cross-domain browser cookies for embedded auth. TCM-owned pages may use cookies such as authToken, refreshToken, tcm_ips_token, and tcm_ips_user_id when the user is already browsing on a TCM domain, but a partner domain cannot reliably set or refresh those cookies for thecrimsonmarket.com. Modern browsers also restrict third-party cookies inside iframes.
The recommended iframe login flow is:
- The partner backend provisions or repairs the linked user with
POST /:uiName/external-club/user. - The partner frontend loads the TCM iframe.
- The iframe posts
{ "type": "IFRAME_READY" }to the parent window. - The parent calls a partner-owned backend endpoint, for example
/api/trade/catalog-session. - That backend endpoint authenticates the local partner user, then calls
POST /:uiName/oauth/external-club/sessionwith HTTP Basic auth and the current partner user id asexternalUserId. - The backend returns the bridged TCM
tokenandrefreshTokento the parent page. - The parent posts the token pair back into the iframe with
{ "type": "TOKEN_DELIVERY", "token": "...", "refreshToken": "..." }. - The iframe uses the delivered token as its authenticated TCM session.
Example parent-side handoff:
const iframe = document.getElementById("credit-iframe") as HTMLIFrameElement | null;
const tcmOrigin = "https://www.thecrimsonmarket.com";
window.addEventListener("message", async (event) => {
if (event.origin !== tcmOrigin) return;
if (event.source !== iframe?.contentWindow) return;
if (event.data?.type !== "IFRAME_READY") return;
const response = await fetch("/api/trade/catalog-session", { method: "POST" });
const payload = await response.json();
const session = payload.data || payload;
iframe.contentWindow?.postMessage(
{
type: "TOKEN_DELIVERY",
token: session.token,
refreshToken: session.refreshToken,
},
tcmOrigin,
);
});The Charades Collective implementation follows this pattern for catalog, vault, and wallet embeds. Email/password and Google users both authenticate to Charades first. Charades then uses the stored external-club link to request a TCM bridge session and delivers that session to the iframe. If the local login path has not finished creating the external-club link yet, the backend bridge endpoint should repair the link before requesting the TCM session.
Resolve an external-club token for a wallet embed
Use this endpoint when a TCM-owned wallet surface, such as an IPS wallet iframe, needs to resolve a bridge token that was minted by a partner external-club client. This keeps the partner external-club secret separate from the wallet/core client secret.
The resolving wallet app must have the wallet_embed scope enabled in Developers. In the app settings, enable the Wallet Embed checkbox.
Endpoint
POST /oauth/external-club/session/delegate/resolveTenant-prefixed deployments may also expose:
POST /:uiName/oauth/external-club/session/delegate/resolveFor production IPS wallet embeds, use:
POST https://www.thecrimsonmarket.com/mana/oauth/external-club/session/delegate/resolveAuthentication
Use HTTP Basic auth with the wallet/core OAuth client credentials:
clientIdclientSecret
The wallet/core client must be active and allowed to use wallet_embed.
Request body
{
"token": "partner_external_club_bridge_token"
}Validation behavior
Portal.Serviceverifies the bridge token signature and expiry.- The token must resolve to a real portal user.
- That user must be linked to an external-club OAuth client.
- The source external-club client must still be active and allowed to use
external_club. - The resolving wallet/core client does not need to match the source external-club client, but it must have
wallet_embed.
Success response
{
"userId": "portal-user-id",
"userName": "partner_member",
"email": "[email protected]",
"hubUserId": "ips-member-id",
"delegatedTo": {
"clientId": "wallet-core-client-id",
"scope": "wallet_embed"
},
"externalClub": {
"clubName": "charadescollective",
"externalUserId": "charades-user-id",
"hubClubId": "hub-3456",
"oauthClientId": "charades-external-club-client-id"
}
}Example server request
const credentials = Buffer.from(`${walletClientId}:${walletClientSecret}`).toString("base64");
const response = await fetch(
"https://www.thecrimsonmarket.com/mana/oauth/external-club/session/delegate/resolve",
{
method: "POST",
headers: {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
token: bridgeTokenFromIframe,
}),
},
);This endpoint is intended for server-to-server wallet validation. Do not call it directly from browser code and do not expose the wallet/core client secret.
Resolve an embedded session token
Use this when your own backend receives a TCM session token from another page, embedded experience, or redirect-driven integration and needs to validate it before establishing a local session.
Endpoint
POST /oauth/external-club/session/resolveTenant-prefixed deployments may also expose:
POST /:uiName/oauth/external-club/session/resolveAuthentication
Use HTTP Basic auth with your OAuth client credentials:
clientIdclientSecret
The client must be active and allowed to use external_club.
Request body
{
"token": "tcm_user_token"
}Validation behavior
Portal.Serviceverifies the token signature and expiry.- The token must resolve to a real portal user.
- That user must still be linked to the same external-club OAuth client that is calling the endpoint.
- If the token is invalid, expired, or belongs to a user linked to a different client, the request is rejected.
Success response
{
"userId": "portal-user-id",
"userName": "partner_member",
"email": "[email protected]",
"externalClub": {
"clubName": "partnerclub",
"externalUserId": "partner-user-123",
"hubClubId": "hub-3456",
"oauthClientId": "tcm_xxx"
}
}Example server request
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
const response = await fetch(`${TCM_OAUTH_API_URL}/oauth/external-club/session/resolve`, {
method: "POST",
headers: {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
token: tcmSessionToken,
}),
});This endpoint is intended for server-to-server validation. Do not expose the OAuth client secret or session resolution request directly from the browser.
OAuth userinfo enrichment
When a linked external-club user later authenticates with OAuth, Portal.Service can append partner-owned data under externalClubContext in /oauth/userinfo.
The enrichment call is optional. If no webhookEventEndpoint is stored, or the callback fails, normal OAuth login still succeeds and Portal.Service falls back to its normal userinfo behavior.
Signed callback request
Portal.Service signs the enrichment callback with the OAuth client secret stored for the linked user.
POST https://pokecharades.example.com/api/v1/public/external-club/user-context
x-external-club-client-id: tcm_xxx
x-external-club-timestamp: 1741702982
x-external-club-signature: <hmac_sha256_hex>{
"clubName": "charadescollective",
"externalUserId": "113769124091223507890"
}Resulting `/oauth/userinfo` shape
{
"sub": "67d0...",
"tcmid": "11735",
"email": "[email protected]",
"externalClubContext": {
"clubName": "charadescollective",
"externalUserId": "113769124091223507890",
"externalClubUser": {
"isSubscribed": true,
"tierName": "Gold",
"billingCycle": "monthly"
}
}
}Operational notes
- The enrichment webhook receives only partner lookup context and must return only partner-owned data.
- The
external_clubscope does not add fixed userinfo fields by itself; it enables the provisioning and enrichment flow. - Keep the OAuth client secret server-side. It is required for provisioning requests and for signing webhook enrichment callbacks.
- If you want to host the TCM catalog inside your own app, continue with Embedded Catalog Integration.
- For general client creation and redirect URI setup, see OAuth App Setup.