SSO / token-exchange
Exchange an external user identifier for an affiliate JWT per RFC 8693. Embed the MeerPartners influencer cabinet into your product without a second registration. Fields, response and example.
Token-exchange lets you authorize your own users inside the affiliate program without forcing them to register with MeerPartners or sending them through MeerID. Your backend exchanges its user identifier (e.g. a Telegram user id or an internal UUID) for a short-lived affiliate JWT with the affiliate:embed scope. With this token you can open the affiliate widgets right inside your interface.
The mechanics follow RFC 8693 (OAuth 2.0 Token Exchange).
/api/v1/auth/token-exchange🔒 HMAC (scope sso)The request is signed with HMAC over the secret of an API key with the sso scope — the same scheme as the postback. Headers and algorithm: API keys and signing.
When you need this
- You have your own product with authorized users (a website, an app, a Telegram Mini App).
- You want to give them an influencer cabinet (links, statistics, payouts) inside the product.
- You do not want a second registration and do not want to route them through the MeerID sign-in screen.
If you only need to send conversions, token-exchange is not required — the postback is enough.
How it works
Your frontend Your backend MeerPartners
│ │ │
│ open "Affiliate" │ │
│ ────────────────────► │ │
│ │ POST /api/v1/auth/token-exchange
│ │ (HMAC over the scope-sso key) │
│ │ ────────────────────────►│
│ │ │ resolve key → tenant
│ │ │ find-or-create the partner
│ │ │ issue a scoped JWT
│ │ ◄──── access + refresh ──│
│ ◄── scoped token ─────│ │
│ open the widgets with this token │The tenant is resolved only from the API key in the database — a tenant field in the body is ignored (protection against spoofing). If the partner under your subject_token does not exist yet, the platform creates it on the fly (JIT) under race protection.
Request fields
The body is JSON.
grant_typestringoptionalThe grant type. The default and expected value is urn:ietf:params:oauth:grant-type:token-exchange.
subject_tokenstringrequiredYour user identifier (1–255 characters). The platform does not interpret it — it stores it as an opaque string to bind your user to an affiliate profile.
subject_token_typestringoptionalThe subject type. Defaults to external_user_id.
subject_profileobjectoptionalAn optional profile, applied only on first creation of the partner (JIT). The recognized keys are: email, display_name, username (if there is no display_name, username is used). This is untrusted data — it does not affect an existing user.
The tenant in the body is ignored
Do not try to pass a tenant in the request body — it is taken from the API key anyway (anti-IDOR). One key works strictly with its own business.
Response
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "bearer",
"expires_in": 600,
"scope": "affiliate:embed",
"affiliate_id": 8123,
"tenant_id": 17
}| Field | Value |
|---|---|
access_token | Scoped JWT, scope affiliate:embed, TTL 10 minutes |
refresh_token | A standard refresh (TTL 30 days) — extends the session without another exchange |
expires_in | The access token's TTL in seconds (600) |
scope | Always affiliate:embed |
affiliate_id | The affiliate profile ID — so the SDK does not have to call /me right away |
tenant_id | Your business ID (from the key) |
A scoped token ≠ full access
The affiliate:embed token is trimmed: it is fine for the influencer-cabinet widgets but does not allow "dangerous" actions. For example, "sign out of all sessions" (/api/v1/auth/logout-all) and role onboarding require a full token and will return EMBED_SCOPE_DENIED (403) for an embed token.
Rejection codes
| Code | HTTP | When |
|---|---|---|
TENANT_API_KEY_INVALID | 401 | Key is unknown, inactive or expired; the X-Api-Key-Id header is missing |
TENANT_API_KEY_SCOPE_DENIED | 403 | The key lacks the sso scope |
EXCHANGE_DENIED | 401 | The signature is invalid, X-Timestamp is missing/expired, or the tenant is inactive |
EXCHANGE_REPLAY | 401 | The same signature replayed within the memory window |
USER_BANNED | 403 | The found user is banned |
SERVICE_UNAVAILABLE | 503 | The replay-protection store is unavailable (fail-closed) — try again later |
The freshness of X-Timestamp is the same ±300 s as for the postback. The error envelope format — API error codes.
Example
Exchanging an internal user id for a scoped token. The signature is computed over the raw request body.
API_BASE="https://api.meerpartners.com"
KEY_ID="key_sso_77"
SECRET="sk_live_…"
BODY='{"grant_type":"urn:ietf:params:oauth:grant-type:token-exchange","subject_token":"vpn-user-99281","subject_token_type":"external_user_id","subject_profile":{"email":"u@vpn.io","display_name":"Neo"}}'
TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
SIGNING=$(printf '%s\n%s\n%s\n%s' "POST" "/api/v1/auth/token-exchange" "$TS" "$BODY_HASH")
SIG=$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -sS -X POST "$API_BASE/api/v1/auth/token-exchange" \
-H "Content-Type: application/json" \
-H "X-Api-Key-Id: $KEY_ID" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
--data "$BODY"