Sign-in with Syndicate
People already have a Syndicate account for Resonance, ASAI, Apeiron and the rest of the platform. Your app can let them use it instead of a new password. The flow is OAuth 2.1 authorization code with PKCE.
Register a client
In Dev Studio open Sign-in apps and create an application:
- Name — shown to the person on the consent screen.
- Redirect URIs — up to 10, matched exactly. For a native app use a loopback address (
http://127.0.0.1/callback): the port is not compared, so you can pick a free one at run time. - Scopes — what the app may ever ask for.
emailandsubscriptionneed a reason in your developer application.
You get a client_id (asc_…) immediately. A client secret is shown once, but the token endpoint does not use it: authentication is PKCE only. Treat every client as public and never ship the secret in a browser bundle or an app binary. One account can hold up to 10 active clients.
Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
GET | https://auth.aperturesyndicate.com/api/v1/sso/authorize | Start sign-in in the browser |
POST | https://auth.aperturesyndicate.com/api/v1/sso/token | Exchange the code, refresh tokens |
GET | https://auth.aperturesyndicate.com/api/v1/sso/userinfo | Who signed in, filtered by scope |
GET | https://auth.aperturesyndicate.com/.well-known/jwks.json | Public keys for verifying tokens |
GET | https://auth.aperturesyndicate.com/.well-known/oauth-authorization-server | RFC 8414 metadata |
The metadata document also lists scopes for AI agents connecting to our MCP server. A client registered in Dev Studio can request only the four scopes below.
Scopes
| Scope | Adds to /userinfo |
|---|---|
openid | sub — the stable account id |
profile | nickname, avatar_url, profile_url |
email | email, email_verified |
subscription | subscription — active plan and features, or null |
The person's internal username is never exposed — not in /userinfo and not in the token. Link accounts by sub.
1. Send the person to authorize
Generate a random code_verifier (43–128 characters), and its code_challenge = base64url(SHA-256(verifier)). Then redirect:
https://auth.aperturesyndicate.com/api/v1/sso/authorize
?response_type=code
&client_id=asc_…
&redirect_uri=https://yourapp.example/callback
&scope=openid profile
&state=<random>
&code_challenge=<challenge>
&code_challenge_method=S256| Parameter | |
|---|---|
response_type | Always code |
client_id | From Dev Studio |
redirect_uri | One of the registered URIs |
scope | Space-separated, within the client's scopes |
state | Required. Check it on the way back |
code_challenge_method | Only S256; plain is refused |
prompt | Optional. none never shows a screen and returns error=login_required if the person is not signed in |
screen | Optional. register sends a signed-out person to account creation instead of sign-in |
A signed-out person signs in first. Then they always see a consent screen with your app's name and the scopes — third-party apps never get a code silently. If they approve, the browser comes back to:
https://yourapp.example/callback?code=…&state=…The code lives 10 minutes and works once. On refusal or failure the redirect carries error instead — login_required or consent_required with prompt=none.
2. Exchange the code
curl -X POST https://auth.aperturesyndicate.com/api/v1/sso/token \
-H "content-type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "asc_…",
"code": "…",
"redirect_uri": "https://yourapp.example/callback",
"code_verifier": "…"
}'{
"access_token": "eyJ…",
"refresh_token": "…",
"token_type": "Bearer",
"expires_in": 900,
"scope": "openid profile"
}The endpoint accepts JSON or application/x-www-form-urlencoded. A form request gets errors in the RFC 6749 shape ({"error":"invalid_grant","error_description":"…"}); a JSON request gets {"error":"…","code":"…"}.
3. Refresh
The access token lives 15 minutes, the refresh token 30 days. Every refresh returns a new refresh token — store it and drop the old one.
{ "grant_type": "refresh_token", "client_id": "asc_…", "refresh_token": "…" }A refresh token works only for the client it was issued to. If the person signs out everywhere, is banned or deletes the account, refresh fails and they have to sign in again.
4. Who signed in
curl https://auth.aperturesyndicate.com/api/v1/sso/userinfo \
-H "Authorization: Bearer eyJ…"{
"sub": "5f0c…",
"nickname": "Nova",
"avatar_url": "https://…",
"profile_url": "https://aperturesyndicate.com/u/nova",
"email": "…",
"email_verified": true
}With subscription:
"subscription": {
"plan": "PREMIUM",
"status": "ACTIVE",
"features": ["lq"],
"items": [],
"current_period_end": "2026-11-29T00:00:00.000Z",
"cancel_at_period_end": false
}Check features, not plan — plan is a legacy field. status is CANCELLED when the subscription runs to the end of the period and stops.
Verifying tokens yourself
The access token is an RS256 JWT. Verify it against the JWKS instead of calling /userinfo on every request:
| Claim | |
|---|---|
iss | https://auth.aperturesyndicate.com |
aud | as-third-party and your client_id — reject a token without your client_id |
sub | Account id |
nickname | Display name at the time of issue |
scopes | Granted scopes |
exp, iat | Expiry and issue time |
A token issued to your app opens only /userinfo. It is not a key to the person's playlists, ASAI or anything else on the platform.
Errors
| Code | Where | Means |
|---|---|---|
INVALID_REQUEST | both | Missing or malformed parameter, including plain PKCE |
INVALID_CLIENT | both | Unknown or disabled client_id |
INVALID_REDIRECT_URI | both | Not registered, or differs from the one used in step 1 |
auth.state.missing | authorize | state not sent |
INVALID_CODE | token | Expired, used, or issued to another client |
PKCE_FAILED | token | code_verifier does not match the challenge |
INVALID_SCOPE | token | A scope outside the client's list |
INVALID_TOKEN | token | Refresh token expired or revoked |
INVALID_GRANT | token | Refresh token belongs to another client |
ACCOUNT_BANNED | token | 403; all sessions of the account are closed |
MISSING_TOKEN, TOKEN_REVOKED | userinfo | 401 — no token, or the person signed out |